---
title: "Broker authentication"
description: "Enable JWT on the broker in two commands, then wire the algorithms, JWKS rotation, and the five access levels."
---

> Queen MQ documentation, for AI agents
> Complete self-contained summary of Queen MQ: https://queenmq.com/llms-brief.txt
> Fetch that first when the question is about the product rather than about this page.
> Index of all pages: https://queenmq.com/llms.txt

# Broker authentication

`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](/deploy/):

```bash
JWT_SECRET="$(openssl rand -hex 32)"; echo "$JWT_SECRET"
```

```bash
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](#boot-time-validation-is-fatal).

## 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-Length`d 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 |

> **Caution**
>
> A token with no `exp` never expires. The library's own "exp is required" rule is
> switched off on purpose for compatibility, so a mint bug that omits `exp` produces
> a permanent credential and nothing in the broker will complain. Require `exp` at the
> issuer.

## 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*`, `/api/v1/kv*`, `/api/v1/timers*` and `/api/v1/ephemeral/queues*` (the two ephemeral status reads), plus `/streams/v1/state/get` |
| `write-only` | `POST /api/v1/push`, `POST /api/v1/timers` (scheduling a timer is a produce operation), and `POST /api/v1/ephemeral/push` |
| `read-write` | Everything else: pop, ack, transaction, lease extension, `/configure`, seeks, subscription changes, `POST /api/v1/traces`, message deletes, KV and timer writes (the timer cancel included), the remaining ephemeral verbs, 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](/reference/http/routes/).

> **Note**
>
> Turning JWT on turns the broker-served dashboard off, deliberately: the app sends
> no bearer token and the broker holds no accounts, so `/auth/me` answers `401` and
> `/auth/login` serves a static page pointing at the proxy. `JWT_SKIP_PATHS` cannot
> rescue it (the shell would load, then every `/api/v1/*` call behind it answers
> `401`). A dashboard with sessions belongs behind `queen_proxy`; with auth off, the
> broker serves the open [standalone dashboard](/deploy/dashboard/).

## 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:

```bash
JWT_ROLES_CLAIM=scope
```

```bash
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`, `HS384`, `HS512` 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` outside `HS256`, `HS384`, `HS512`, `RS256`, `RS384`,
  `RS512`, `EdDSA`, `auto`. The failure names the whole set.

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](/deploy/multi-tenant/).
- **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](/deploy/security/).

Source: https://queenmq.com/reference/security/jwt/index.mdx
