Documents ZigTSDB v1.6.0 · applies to the 1.6.x line
Authentication & tokens
How a client proves who it is, how a token is minted, refreshed, revoked and expires, and how the signing secret rotates without an outage. Every command on this page runs against the published image; the cluster it assumes is the one from the quickstart.
Closed by default
Every read and write needs a token. Only /health and /ready
answer without one. What a node does at boot depends on what it was given:
| Configuration | Behaviour |
|---|---|
TSDB_AUTH_SECRET set |
Tokens are checked against it. This is what a cluster needs: one shared
secret on every node (the quickstart's .env generates it). |
| Nothing set, single node | The node bootstraps: it generates a signing secret, keeps it
in auth_bootstrap.json (mode 0600) on the data volume, and prints
an admin refresh token once
(take your credential). The secret is
never printed. |
Nothing set, TSDB_PEERS set |
The node refuses to start. A cluster cannot bootstrap: each node would mint a different secret and a token from one would be refused by the next. |
TSDB_ALLOW_OPEN=1 |
Every client endpoint is served unauthenticated and /health
reports "auth_open":1. For trusted networks only. |
TSDB_REQUIRE_AUTH=1 |
The node refuses to start unless client authentication is configured — a guard against a deployment that meant to be closed and is not. |
What a token is
A token is a JWT signed with HMAC-SHA256 over TSDB_AUTH_SECRET, sent as
Authorization: Bearer <jwt>. The header's alg must be
exactly HS256; it is checked before any signature is computed, so a
token naming another algorithm — including none — is refused as
Unsupported token algorithm, never as a signature mismatch. The claims:
| Claim | Meaning |
|---|---|
sub |
Who the token is for. 1–128 printable ASCII characters, no " or
\ — a subject that could smuggle a claim is refused at minting. |
roles |
An array of role names (below). Same character rule. |
iat, exp |
Issued-at and expiry, Unix seconds. nbf is optional. |
token_use |
access (or absent): an API credential. refresh: buys
access tokens only. Anything else counts as access, so a malformed
token can never become a refresh token by accident. |
jti |
The identity a revocation names. Refresh tokens carry one; access tokens do not, which is why access tokens cannot be revoked and are short-lived instead. |
| Role | Permissions |
|---|---|
reader |
read |
writer |
read, write |
admin |
read, write, admin (mint for others, revoke, activate a license, backups) |
A request the data plane refuses answers 401 with the reason in the body:
Missing Authorization header, Invalid token format,
Invalid token signature, Unsupported token algorithm,
Token expired, Token not yet valid,
Invalid token claims, or Refresh token is not an API credential.
A valid token without the permission the route needs answers 403
Insufficient permissions.
Mint a token for a client
POST /auth/token mints tokens for other clients. Two callers may use it, and
nothing else: an admin access token, which may mint for any subject; and,
behind the secure overlay, an mTLS identity
the sidecar stamped on the request, mapped to roles by TSDB_AUTH_SAN_ROLES,
which mints for that identity only. A non-admin access token may not
mint — if it could, every access token would be a refresh token and short lifetimes
would mean nothing.
The request names the subject, its roles and, optionally, ttl_s (clamped to
the configured access lifetime — a caller may ask for less, never more) and
"refresh":true to receive a refresh token as well:
POST /auth/token
Authorization: Bearer <admin access token>
{"sub":"svc-a","roles":["writer"],"ttl_s":600,"refresh":true}
{"access_token":"eyJ…","token_use":"access","expires_in":600,
"refresh_token":"eyJ…","refresh_expires_in":604800}
Delegation, never escalation. A minted token carries only roles whose
every permission the minter already holds. Containment is by permission, not by role
name: an admin holds everything reader grants, so an admin may mint a
read-only token.
| Code | Body | When |
|---|---|---|
| 400 | invalid_body, missing_roles,
missing_sub, invalid_subject |
malformed request, or a subject or role containing " or
\ |
| 401 | auth_required, wrong_token_use |
no credential; a refresh token offered as one |
| 403 | admin_required, role_escalation,
identity_not_permitted |
non-admin minter; roles beyond the minter's authority; an mTLS identity with no mapping |
| 429 | rate_limited |
the admin rate bucket |
| 503 | auth_not_configured |
the node has no signing secret (it is open) |
The first admin credential. A bootstrapped node prints its admin refresh
token at boot. A cluster with an operator secret has no such moment: the first admin token
is signed with the secret itself, which is what the quickstart's
mint step does and what $TOKEN
means below. From there, mint a pair for a service and prove the access half writes —
without the write-forwarding overlay only the node that leads the metric's group
answers 200, the others 503:
$ NODE=http://localhost:8080
$ PAIR=$(curl -s -X POST "$NODE/auth/token" -H "Authorization: Bearer $TOKEN" \
-d '{"sub":"svc-a","roles":["writer"],"refresh":true}')
$ ACCESS=$(printf '%s' "$PAIR" | sed -E 's/.*"access_token":"([^"]+)".*/\1/')
$ REFRESH=$(printf '%s' "$PAIR" | sed -E 's/.*"refresh_token":"([^"]+)".*/\1/')
$ for n in 8080 8081 8082; do
curl -s -o /dev/null -w "$n %{http_code}\n" -X POST "http://localhost:$n/ingest" \
-H "Authorization: Bearer $ACCESS" -d "svc_metric,host=svc-a value=1 $(date +%s)000000000"
done
Hand svc-a its refresh token, not the access token: the refresh token is
what it exchanges for new access tokens on its own, and the one you can kill.
Refresh
POST /auth/refresh exchanges a refresh token for an access token. The refresh
token is the credential — no admin token, no certificate — and the body
is {"refresh_token":"…"}. What it buys is deliberately narrow: an access
token with the same subject and roles (an exchange can never gain authority),
carrying no jti, so it cannot itself be exchanged again. Refresh tokens are
reusable until they expire or are revoked; they are not rotated per exchange, which keeps
concurrent clients simple and makes revocation the one kill switch.
A refresh token is not an API credential. Presented as the bearer on any
data route it is refused 401 Refresh token is not an API credential, however
valid its signature: the data plane never consults the revocation list, so honouring it
there would make a leaked refresh token as good as the data even after revocation.
$ ACCESS2=$(curl -s -X POST http://localhost:8081/auth/refresh \
-d "{\"refresh_token\":\"$REFRESH\"}" | sed -E 's/.*"access_token":"([^"]+)".*/\1/')
$ Q="http://localhost:8082/query?zql=SELECT%20avg(value)%20FROM%20svc_metric"
$ curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $ACCESS2" "$Q"
$ curl -s -H "Authorization: Bearer $REFRESH" "$Q"
200
{"error":"Refresh token is not an API credential"}
| Code | Body | When |
|---|---|---|
| 400 | missing_refresh_token |
no token in the body |
| 401 | auth_required, token_expired,
wrong_token_use, revoked |
bad signature or format; expired — get a new pair; an access token (or a
refresh token without a jti) offered instead; revoked |
| 503 | auth_not_configured |
the node has no signing secret |
The CLI's credential
zigtsdb-cli sends a credential on every command that talks to a node. It reads
two variables, and the difference matters:
| Variable | Shape | Revocable? |
|---|---|---|
TSDB_REFRESH_TOKEN |
A refresh token, exchanged on each invocation for a short-lived access token. The refresh token never reaches an API route — only the access token it buys does — so capturing CLI traffic yields at most a credential that expires on its own. | Yes — revoke it |
TSDB_AUTH_TOKEN |
A static access token, sent as-is. | No — only a rotation of TSDB_AUTH_SECRET kills it, and that
invalidates everyone else's tokens too |
TSDB_REFRESH_TOKEN wins when both are set: an operator who has configured both
is mid-migration, and preferring the static one would silently keep the un-revocable
credential in play. A rejected exchange is an error, not a fallback: if
the refresh token has been revoked or has expired, the CLI says so and exits 1 rather than
quietly retrying with the static token and defeating the revocation:
auth: the refresh token was rejected (RefreshRejected) — it may have been
revoked or expired. Not falling back to TSDB_AUTH_TOKEN: that credential
cannot be revoked.
The image ships the CLI at /app/zigtsdb-cli, so a service's pair can be
tried from inside any node. With the refresh token minted above:
$ docker compose -f docker-compose.ha-3node.yml exec -T \
-e TSDB_REFRESH_TOKEN="$REFRESH" node0 /app/zigtsdb-cli license; echo "exit $?"
Exit codes: 0 the command did what it said; 1 it failed — a
non-2xx answer (Error: HTTP <n> plus the body), a transport error, a
rejected exchange; 2 invalid arguments. Scripts written against a 1.5 or
older CLI, which printed the error and still exited 0, must check the status they were
already meant to check. Later CLI releases add auth commands for the minting
and revocation steps shown with curl on this page.
Revoke
POST /auth/revoke kills a refresh token. It is admin-gated and takes either
the token itself ({"refresh_token":"…"}) or its identity from an audit line
({"jti":"<32 hex>"}). It is idempotent, and answers
200 {"revoked":"<jti>"}.
The revocation is a Raft write: it replicates to every node, because a
denylist that reached only one of them is defeated by retrying the refresh against
another. Send it to the leader — a follower answers 503 with a
leader_url to follow, the same hint a write gets. It is refused with
409 while any peer runs a release before 1.6.0, since a node that cannot
read the entry would silently drop it and go on honouring the token; finish the rollout
first. Revocations survive a restart and a snapshot (every node keeps
token_revocations.json in its data directory); a node downgraded below
1.6.0 forgets them. Access tokens cannot be revoked — they expire within the access
lifetime, which is why it is short.
Decode the refresh token's jti — the payload is plain base64url, no key
needed — and revoke around the ring until a node other than a follower answers:
$ claim() { p=$(printf '%s' "$1" | cut -d. -f2 | tr '_-' '/+'); pad=$(( (4 - ${#p} % 4) % 4 ));
printf '%s%s' "$p" "$(printf '%*s' "$pad" '' | tr ' ' '=')" | openssl base64 -d -A \
| grep -oE "\"$2\":\"[^\"]*\"" | cut -d'"' -f4; }
$ JTI=$(claim "$REFRESH" jti)
$ for n in 8080 8081 8082; do
code=$(curl -s -o revoke.json -w '%{http_code}' -X POST "http://localhost:$n/auth/revoke" \
-H "Authorization: Bearer $TOKEN" -d "{\"jti\":\"$JTI\"}")
[ "$code" = 503 ] || break
done
$ cat revoke.json
{"revoked":"3f9c…"}
From that moment the refresh token is dead on every node, and the CLI holding it says so. The access tokens it already bought keep working until they expire:
$ curl -s -X POST http://localhost:8082/auth/refresh -d "{\"refresh_token\":\"$REFRESH\"}"
$ docker compose -f docker-compose.ha-3node.yml exec -T \
-e TSDB_REFRESH_TOKEN="$REFRESH" node0 /app/zigtsdb-cli license; echo "exit $?"
$ curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $ACCESS2" "$Q"
{"error":"revoked"}
auth: the refresh token was rejected (RefreshRejected) — it may have been revoked or expired. …
exit 1
200
| Code | Body | When |
|---|---|---|
| 400 | missing_target, invalid_refresh_token,
invalid_jti |
no or unusable target |
| 401 / 403 | auth_required, admin_required |
not an admin access token |
| 409 | revocation_unavailable_mixed_version |
a peer runs a release before 1.6.0 |
| 503 | not_leader, with leader_url |
follow the hint |
Lifetimes and clock skew
| Variable | Default | Notes |
|---|---|---|
TSDB_AUTH_ACCESS_TTL_S |
900 (15 min) |
Lifetime of an access token minted by /auth/token or
/auth/refresh. Clamped to [301, 2592000]; an
unparseable, zero or negative value falls back to the default. |
TSDB_AUTH_REFRESH_TTL_S |
604800 (7 days) |
Lifetime of a refresh token. Same bounds. |
The lower bound is one second above the 300 s clock-skew grace, because a lifetime at or
below the grace is meaningless; the upper bound is 30 days, because a year-long
"short-lived" token is the problem short lifetimes exist to solve. A ttl_s in
a mint request above the configured lifetime is clamped, not refused.
Time claims are checked with 300 s of tolerated clock skew: a token is
rejected as expired only once now > exp + 300, and as not yet valid when
nbf (or a future-dated iat) is more than 300 s ahead. The grace
exists so an NTP correction cannot cause a cluster-wide authentication outage; the cost is
that a token stays usable for up to 300 s past its stated expiry, which is why short
lifetimes matter.
Through the dispatcher
The dispatcher has a credential of its own,
TSDB_DISPATCHER_AUTH_TOKEN, which authenticates a caller to the
dispatcher; writes need only that, because the dispatcher forwards them to the nodes
on its internal scope. A query is different: it is answered by a node's own
/query, which checks the caller's JWT, so the dispatcher forwards the
Authorization header it received verbatim — send the cluster token on queries
through the dispatcher. A node's 401 or 403 on a query is the
caller's answer: the dispatcher returns it as its own status, never a 200 or a
retryable 503, and does not fail over to another replica. The
/auth/* routes are node routes; the dispatcher does not serve them.
Rotate the signing secret with no downtime
A node signs with TSDB_AUTH_SECRET and accepts tokens signed with either it or
TSDB_AUTH_SECRET_NEXT, the outgoing secret. Three phases, each a rolling
restart of every node:
- Introduce.
TSDB_AUTH_SECRET=<new> TSDB_AUTH_SECRET_NEXT=<old>. New tokens are signed with the new secret; everything clients still hold keeps working. Restart node by node. - Wait at least
TSDB_AUTH_REFRESH_TTL_S+ 300 s (the clock-skew grace): by then every refresh token minted under the old secret has expired or been exchanged for an access token signed with the new one. Clients that hold a staticTSDB_AUTH_TOKENmust be re-issued in this window — a static token has no refresh path. - Retire. Drop
TSDB_AUTH_SECRET_NEXT, restart node by node. Anything still signed with the old secret is refused asInvalid token signature.
TSDB_AUTH_SECRET_NEXT without TSDB_AUTH_SECRET refuses to start:
an overlap needs an operator holding both. A revocation is keyed by the token's
jti, not by the secret, so a refresh token revoked during the overlap stays
revoked after it. A bootstrapped single node has no overlap of its own; moving it to an
operator secret is a repo runbook (below).
For the quickstart cluster, phase 1 is this overlay, saved as
docker-compose.ha-3node-rotate.override.yml. It reads the new value from
TSDB_AUTH_SECRET_NEW in .env and demotes the current one to
_NEXT:
# Secret rotation, phase 1 (introduce): every node signs with the new secret
# and still accepts tokens signed with the old one. Use it with the base file,
# one node at a time:
#
# docker compose -f docker-compose.ha-3node.yml \
# -f docker-compose.ha-3node-rotate.override.yml up -d --no-deps node0
x-rotate: &rotate
TSDB_AUTH_SECRET: ${TSDB_AUTH_SECRET_NEW:?set TSDB_AUTH_SECRET_NEW in .env}
TSDB_AUTH_SECRET_NEXT: ${TSDB_AUTH_SECRET:?set TSDB_AUTH_SECRET in .env}
services:
node0:
environment: *rotate
node1:
environment: *rotate
node2:
environment: *rotate
Generate the new secret and roll the overlay through the nodes, waiting for each to answer
before the next. Tokens signed with the old secret — $TOKEN among them — keep
working throughout:
$ printf 'TSDB_AUTH_SECRET_NEW=%s\n' "$(openssl rand -hex 32)" >> .env
$ for n in node0 node1 node2; do
docker compose -f docker-compose.ha-3node.yml \
-f docker-compose.ha-3node-rotate.override.yml up -d --no-deps "$n"
until curl -fsS -m 3 "http://localhost:$((8080 + ${n#node}))/health" >/dev/null 2>&1; do sleep 2; done
done
After the wait, retire the old secret: make the new value TSDB_AUTH_SECRET
in .env and roll the nodes once more, this time without the overlay,
so nothing is set as _NEXT:
$ NEW=$(grep '^TSDB_AUTH_SECRET_NEW=' .env | cut -d= -f2-)
$ { grep -vE '^TSDB_AUTH_SECRET(_NEW)?=' .env; printf 'TSDB_AUTH_SECRET=%s\n' "$NEW"; } > .env.retire
$ mv .env.retire .env && chmod 600 .env
$ for n in node0 node1 node2; do
docker compose -f docker-compose.ha-3node.yml up -d --no-deps "$n"
until curl -fsS -m 3 "http://localhost:$((8080 + ${n#node}))/health" >/dev/null 2>&1; do sleep 2; done
done
A request with the old $TOKEN now answers 401 Invalid token
signature; mint a new admin token from the new secret the same way as the first.
Tokens minted during the overlap are signed with the new secret and are unaffected.
Rotate the scoped internal tokens
The secure overlay gives the nodes four
scoped bearer tokens — raft_peer, cluster_admin,
dispatcher, block_transfer — each gating one internal surface.
A node accepts two values per scope during a rotation: the file named by
TSDB_<SCOPE>_TOKEN_FILE and the one named by
TSDB_<SCOPE>_TOKEN_NEXT_FILE (TSDB_RAFT_PEER_TOKEN_NEXT_FILE,
TSDB_CLUSTER_ADMIN_TOKEN_NEXT_FILE, TSDB_DISPATCHER_TOKEN_NEXT_FILE,
TSDB_BLOCK_TRANSFER_TOKEN_NEXT_FILE). The order matters — receivers first,
senders second, retire last:
- Write the next value the way the generator wrote the first
(
openssl rand -hex 32 > secure/tokens/<scope>.next.token). - Set
TSDB_<SCOPE>_TOKEN_NEXT_FILEon every node and restart them; the nodes now accept both values. - Switch every sender to the new value — the other nodes' outbound
use of the scope, and for
dispatcherthe dispatcher's own forward token — and restart them. - Move the new value into
TSDB_<SCOPE>_TOKEN_FILE, unset theNEXTfile, restart.
In the secure overlay, step 2 is one line per scope under x-secure-env,
for example:
x-secure-env: &secure-env
TSDB_DISPATCHER_TOKEN_FILE: /run/tsdb-secrets/dispatcher.token
TSDB_DISPATCHER_TOKEN_NEXT_FILE: /run/tsdb-secrets/dispatcher.next.token
Read more
The ZigTSDB repository's docs/SECURITY.md holds the full credential
inventory, the move from a bootstrapped secret to an operator secret, certificate
rotation and the compromise-response order; docs/HTTP_API.md is the
complete endpoint reference. Questions go to
contact@anzaran.com.