Skip to content

Broker authentication

Enable JWT on the broker in two commands, then wire the algorithms, JWKS rotation, and the five access levels.

Updated View as Markdown

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/queen

Then 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=scope
JWT_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:

  • HS256 or auto with none of JWT_SECRET, JWT_PUBLIC_KEY, JWT_JWKS_URL set.
  • RS256, RS384, RS512 or EdDSA with neither JWT_PUBLIC_KEY nor JWT_JWKS_URL set.
  • Any JWT_ALGORITHM value 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 remember exp is optional to the validator.
  • Transport security. The token travels over the listener’s plaintext HTTP. See Trust boundaries.
Navigation

Type to search…

↑↓ navigate↵ selectEsc close