JWT_ENABLED defaults to false: off, the middleware stamps an empty identity
into every request. On, it verifies the signature, checks the temporal claims, and
compares the token’s roles against the level the route requires. One middleware,
one route table: no per-queue authorization, no tenant binding, no session state.
Turn it on
Generate the secret and keep it, because minting a token needs the same bytes. Paths per Deploy a broker:
JWT_SECRET="$(openssl rand -hex 32)"; echo "$JWT_SECRET"JWT_ENABLED=true JWT_ALGORITHM=HS256 JWT_SECRET="$JWT_SECRET" server/target/release/queenThen read the boot log: JWT auth ENABLED carries the resolved algorithm and the
skip list, and a JWKS URL adds JWKS pre-fetch OK with a key count. In the
published container the binary is /app/bin/queen (the image’s default command),
so the same variables travel as docker run -e flags. Configuration is read once
at boot; there is no reload.
Key material matches the algorithm family: JWT_SECRET for symmetric
HS256/HS384/HS512 (the default), JWT_PUBLIC_KEY (a PEM) or JWT_JWKS_URL
for RS256/RS384/RS512/EdDSA, which let an identity provider hold the
private key. If they disagree, the broker does not start:
boot-time validation.
Algorithms and auto-detection
JWT_ALGORITHM (default HS256) does not name the algorithm the broker uses. It
names the set a token may declare in its header; the broker dispatches on the
token’s own alg.
JWT_ALGORITHM |
Token alg values accepted |
|---|---|
auto |
HS256, HS384, HS512, RS256, RS384, RS512, EdDSA |
HS256 |
HS256 |
HS384 |
HS384 |
HS512 |
HS512 |
RS256 |
RS256, RS384, RS512 |
RS384 |
RS384 |
RS512 |
RS512 |
EdDSA |
EdDSA |
RS256 is the widest RSA setting (RS384 and RS512 are exact), and auto
accepts both families at once, so a deployment holding both an HS secret and an
RSA public key verifies a token signed with either. Pin the algorithm unless the
token really must choose. Anything outside the set is a
401 Unsupported token algorithm before any key lookup.
Where the verification key comes from
The key is chosen per token, from the token’s alg:
Token alg |
Key used | Server-side failure |
|---|---|---|
HS256/384/512 |
JWT_SECRET, as raw bytes |
Empty secret: 500 HS secret not configured, not a 401 |
RS256/384/512 |
JWT_PUBLIC_KEY parsed as an RSA PEM; if empty, the JWKS cache by kid |
Unparseable PEM: 500 invalid RSA public key PEM |
EdDSA |
JWT_PUBLIC_KEY parsed as an Ed25519 PEM; if empty, the JWKS cache by kid |
500 invalid Ed public key PEM |
A static PEM always wins over JWKS, and there is no fallback from a bad PEM to the JWKS endpoint: fix the PEM or unset it.
A JWKS endpoint
Set JWT_JWKS_URL and leave JWT_PUBLIC_KEY empty: the broker pre-fetches the
document at boot and caches the keys by kid.
| Variable | Default | Effect |
|---|---|---|
JWT_JWKS_URL |
(empty) | The endpoint. Fetched with a plain GET, no redirects followed |
JWT_JWKS_REFRESH_INTERVAL |
3600 |
Seconds between background refreshes |
JWT_JWKS_TIMEOUT_MS |
5000 |
Per-fetch timeout |
Behaviour that matters in production:
| Behaviour | Detail |
|---|---|
Unknown kid |
One re-fetch, throttled to at most once every 5 seconds, then a second lookup; still unknown is 401 Unknown key ID. A key rotation is picked up without a restart and without a fetch stampede |
Empty kid |
Matches the first key in the cache. Convenient for a single-key set, imprecise for a rotating one: publish kid on both sides |
| Failed pre-fetch | Not fatal. Boot logs JWKS pre-fetch failed (will retry on demand) and carries on; the first token pays the fetch |
| Key types read | Only RSA (from n/e) and OKP with crv: Ed25519 (from the raw 32-byte x). Every other entry is skipped silently, so a populated-looking document can yield zero usable keys: check the key count in the boot log |
| Transport | Body capped at 1 MiB, Content-Lengthd or chunked. HTTPS chains are verified against the root set bundled into the binary |
http:// URLs |
Accepted, with a boot warning that cleartext-fetched signing keys are MITM-forgeable. Treat that warning as an error in your own review |
Claim validation
Signature verification is the JWT library’s; every other check is the broker’s
own, matching the older implementation exactly. The skew is JWT_CLOCK_SKEW,
default 30 seconds, applied to all three temporal checks.
| Claim | Rule |
|---|---|
exp |
Optional. If present, rejected once now > exp + JWT_CLOCK_SKEW |
nbf |
If present, rejected while now + skew < nbf |
iat |
If present, rejected when iat > now + skew |
iss |
Checked only when JWT_ISSUER is non-empty; must match exactly |
aud |
Checked only when JWT_AUDIENCE is non-empty; accepts a string or an array, and one match is enough |
sub |
Optional. When present it is stamped as the message’s producerSub, and it is the only source of that field: a producerSub in a push body is not honoured |
Access levels are a role set, not a ladder
Five levels exist and they are not ordered: the checks are role-set membership tests.
| Level | Passed by |
|---|---|
public |
Everyone, token or not |
read-only |
read-only, read-write, admin, but not write-only |
write-only |
write-only, read-write, admin, but not read-only |
read-write |
read-write, admin |
admin |
admin |
So a producer holding only write-only passes POST /api/v1/push and collects
403 Insufficient permissions on every read and consume route: pop, ack,
transaction, listings, all of it. That is the intended shape for a pure producer,
and the first thing to check when a client starts seeing 403s.
The route classification is prefix-based:
| Level | Routes |
|---|---|
public |
/health, /metrics, /metrics/prometheus, /, /assets/*, /favicon*, plus /auth/me, /auth/login, /auth/logout, public so they can answer the auth question rather than be blocked by it (with JWT on, /auth/me returns 401 from its own handler) |
admin |
Everything under /api/v1/system/ and /internal/, every DELETE /api/v1/consumer-groups/*, every DELETE /api/v1/resources/queues/*, and /api/v1/stats/refresh |
read-only |
GET on /status, /api/v1/status*, /api/v1/analytics*, /api/v1/resources/*, /api/v1/messages*, /api/v1/consumer-groups*, /api/v1/dlq*, /api/v1/traces*, plus /streams/v1/state/get |
write-only |
POST /api/v1/push, and only that |
read-write |
Everything else: pop, ack, transaction, lease extension, /configure, seeks, subscription changes, POST /api/v1/traces, message deletes, and any route not listed above |
The last row is the important one: an unrecognised path defaults to read-write,
not to public. The per-route table is the route table.
Remapping claims for an external identity provider
The role names and the claims they live in are configurable, so a token minted by
an existing IdP usually needs a mapping, not custom claims. An IdP that emits
{"scope":"queen.write"} needs two variables and no custom mint:
JWT_ROLES_CLAIM=scopeJWT_ROLE_WRITE_ONLY=queen.write| Variable | Default | Meaning |
|---|---|---|
JWT_ROLES_CLAIM |
role |
Claim holding a single role string |
JWT_ROLES_ARRAY_CLAIM |
roles |
Claim holding an array of role strings |
JWT_ROLE_ADMIN |
admin |
The value that means admin |
JWT_ROLE_READ_WRITE |
read-write |
The value that means read-write |
JWT_ROLE_READ_ONLY |
read-only |
The value that means read-only |
JWT_ROLE_WRITE_ONLY |
write-only |
The value that means write-only |
Both claims are read on every token, and a role counts as held if it matches the
single-value claim or appears in the array claim. Both claim names are literal
top-level keys, not JSON paths: a role buried in a nested object (the
realm_access.roles shape, for instance) is invisible, and the issuer has to lift
it into a top-level claim.
Skipping paths
JWT_SKIP_PATHS defaults to /health,/metrics/prometheus,/metrics,/. An entry
matches a request path exactly; an entry longer than one character that ends in /
also matches as a prefix, so /assets/ skips everything below it while / skips
only the root document. Skips are evaluated before the route’s access level, so an
entry here overrides the table above.
The token on the wire
The credential is read from Authorization: a Bearer prefix is stripped
case-insensitively, and a bare token with no prefix is also accepted. Errors are
the broker’s standard {"error":"…"} JSON shape:
| Status | Message | Cause |
|---|---|---|
| 401 | Authentication required |
No Authorization header on a non-public route |
| 401 | Bad token header |
The token is not a decodable JWT |
| 401 | Unsupported token algorithm |
alg outside the JWT_ALGORITHM set |
| 401 | Invalid token signature |
Signature check failed |
| 401 | Token has expired / Token not yet valid / Token issued in the future |
Temporal checks, with skew applied |
| 401 | Invalid token issuer / Invalid token audience |
JWT_ISSUER / JWT_AUDIENCE mismatch |
| 401 | Unknown key ID |
No JWKS key for the token’s kid, after one refresh |
| 403 | Insufficient permissions |
Valid token, roles do not satisfy the route |
| 500 | HS secret not configured and the two PEM messages |
Server-side key material problem |
Boot-time validation is fatal
When JWT_ENABLED=true, the configuration is validated before the listener binds
and a failure exits the process, naming what is missing:
HS256orautowith none ofJWT_SECRET,JWT_PUBLIC_KEY,JWT_JWKS_URLset.RS256,RS384,RS512orEdDSAwith neitherJWT_PUBLIC_KEYnorJWT_JWKS_URLset.- Any
JWT_ALGORITHMvalue outside the supported list.
This is the one authentication mistake the broker refuses to start with. Everything else (a wrong secret, an unreachable JWKS endpoint, a role mapping that matches nothing) starts cleanly and fails per request, so verify with a real token against a real route before you cut traffic over.
What sits outside JWT
JWT gives one broker a per-route access level for each caller. Four properties come from elsewhere:
- Tenant separation. A valid admin token sees every queue in the broker. Separation is the tenancy header plus a gateway: multi-tenant hosting.
- Per-queue permissions. The access level is per route, not per resource.
- Revocation. No deny-list; a token is valid until
exp. Keep lifetimes short, and rememberexpis optional to the validator. - Transport security. The token travels over the listener’s plaintext HTTP. See Trust boundaries.