The proxy accepts exactly two kinds of credential, and they are not interchangeable. A cluster API key is what a daemon puts in its configuration: opaque, long-lived, bound to one cluster, scoped by capability. A user session is what a human gets from logging in: a proxy-minted JWT, not bound to any cluster, whose permissions are resolved per cluster at request time.
The credential is spent at the proxy. It is stripped before the request is forwarded, and the broker sees only the cell secret. See Deploying queen_proxy.
How a credential is read
From the Authorization header first, then, for some surfaces, from the session
cookie:
-
Authorizationis read and aBearerprefix stripped. A value that starts withqk_is an API key; anything else is treated as a session token. -
If there is no usable
Authorizationheader, the session cookie is considered. An explicit header always wins over the cookie: a token is chosen per request, a cookie merely rides along. -
A cookie whose value starts with
qk_is ignored, not promoted. Nothing mints an API key into a cookie, and a browser-attached credential must never widen what a caller can do.
Cluster API keys
| Property | Value |
|---|---|
| Format | qk_<env>_<43 base64url characters> |
| Entropy | 32 bytes from the OS CSPRNG |
| Stored as | sha256 hex in api_keys.key_hash; the plaintext is shown once |
| Bound to | One cluster, permanently |
| Scopes | Any non-empty subset of produce, consume, admin, read |
| Revocation | api_keys.revoked_at; the lookup filters on revoked_at IS NULL |
Two checks run on every request. The hash must resolve to a live key, and that key’s cluster must be the cluster the request resolved to:
- Unknown or revoked hash →
401 {"code":"unauthorized"}. - A valid key belonging to a different cluster →
403withkey/cluster mismatch.
Revocation propagates two ways: the mutating SQL function fires the invalidation notification, which drops the cached entry immediately, and the positive cache entry expires after 30 seconds anyway. A revoked key can never be resurrected by the pxdb-outage grace window, because a clean “no such row” writes a negative entry that replaces the expired positive one.
An API key can never be an operator, and it can never be retargeted:
x-queen-act-cluster is refused for API keys with a 403 rather than being silently
ignored. A producer that believes it is writing to cluster B while the proxy routes it
to A is the worse failure.
User sessions
A session is a JWT the proxy itself mints and verifies. Claims are
{sub, iss, iat, exp, jti, role, cluster?}, where sub is the user id.
| Aspect | Behaviour |
|---|---|
| Signing | Ed25519 when QUEEN_PROXY_JWT_ED25519_PEM is set, HS256 from QUEEN_PROXY_JWT_SECRET otherwise. Ed25519 wins when both are present |
| Public key | Published at GET /.well-known/jwks.json |
| Lifetime | QUEEN_PROXY_JWT_TTL_S, default 86400 |
cluster claim |
Absent on a session cookie |
role claim |
A placeholder (viewer) on a session cookie |
| Revocation | The jti goes on the revoked_tokens deny-list at logout, checked on every verify |
The two blanks are the point. A session token grants nothing on its own: because it
carries no cluster and its role is a placeholder, the effective role is looked up
live in cluster_roles for the cluster the request resolved to. Deleting a membership
row takes effect within the role cache’s 30-second TTL, without touching the token.
A token that does carry a cluster claim is trusted for that cluster and rejected
for any other (403 token not valid for this cluster). Nothing mints such a token
today; the check exists so a future scoped token cannot be retargeted.
Three caches bound how fast a revocation lands: the deny-list at 60 seconds, the
per-cluster role at 30 seconds, and the operator bit at 30 seconds. When the deny-list
lookup itself errors (a pool or query failure, not a clean miss), the default is to
fail open: a transient pxdb blip must not 401 every session, and the token still
passed signature and expiry. QUEEN_PROXY_REVOCATION_STRICT=true inverts that for
deployments that would rather lose availability.
The session cookie
queen_session=<jwt>; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400Domain is appended when QUEEN_PROXY_COOKIE_DOMAIN is set, and Secure when either
that domain is configured or the request arrived with X-Forwarded-Proto: https.
SameSite=Lax blocks the cookie from cross-site fetch and XHR, which is what lets the
dashboard call the API with plain same-origin requests and no token plumbing. Lax does
not withhold the cookie from a top-level navigation, so one more check closes that
gap: on API paths, a request whose Sec-Fetch-Mode is navigate is treated as having
no credential. Without it, a hostile page could navigate a logged-in browser to
GET /api/v1/pop/queue/orders and lease a tenant’s messages on the cookie alone.
The document surface (the dashboard shell itself) does accept the cookie on a
navigation, because serving static bytes has no state to forge. Non-browser clients
send no Sec-Fetch-* headers and are unaffected either way, as is any request carrying
an Authorization header.
Logout is a revocation, not a cookie clear
POST /auth/logout deny-lists the presented jti first, then clears the cookie.
Clearing alone would only stop that browser from sending a token which stays valid
for the rest of its TTL.
One scope limit: GET /auth/session-token mints a short bearer for the SPA with its
own jti and a 15-minute lifetime, and those are not tracked by the deny-list.
A logout does not kill an already-issued short bearer; it runs out. The mint itself
checks the deny-list first, so a logged-out cookie cannot produce a new one.
The login flow
| Route | Purpose |
|---|---|
GET /auth/login |
The login page, carrying a validated next |
POST /auth/login |
Email and password, verified against the bcrypt hash in users.password_hash |
POST /auth/logout |
Revoke and clear |
GET /auth/me |
What the browser’s session is: identity, tenant, clusters, operator bit. Cookie only |
GET /auth/session-token |
A 15-minute bearer for the SPA |
GET /auth/google → GET /auth/google/callback |
Authorization code plus OIDC id_token |
GET /auth/github → GET /auth/github/callback |
OAuth2 without OIDC |
Both providers are enabled by setting their client id and secret; with those unset the routes have nothing to redirect to.
What the flow enforces:
- Signed
state. A compact HMAC-SHA256 token, not a JWT, carrying the nonce, the validatednextand a 300-second expiry. The key comes fromQUEEN_PROXY_OAUTH_STATE_SECRET, falling back to the JWT HS secret, falling back to a random per-process key. That last fallback works for a single instance and breaks behind a load balancer: two processes cannot verify each other’sstate. Set the variable on any auth host that is not a single process. nextis validated, not trusted. It must start with a single/, must not start with//or/\, must not contain://, and must contain no control characters, so a callback cannot be turned into an open redirect.- Verified email only. Google’s
email_verifiedand GitHub’s primary-and-verified email are the only addresses that can link to an existing user. An unverified address never matches an account. - A per-IP login throttle, 10 attempts per 60 seconds, answered with
429. The IP comes fromX-Forwarded-For(first entry) orX-Real-IP, so the throttle is only as trustworthy as the edge that sets them. - A 10-second timeout on every outbound provider call, and Google’s JWKS cached for an hour.
Resolution order on an OAuth callback
-
A linked
identitiesrow for this(provider, provider_id)→ log in as that user. -
Otherwise, a verified email matching an existing
usersrow → link the identity to that user and log in. -
Otherwise, auto-provision, but only when
QUEEN_PROXY_AUTOPROVISION=true. The new user is created under the tenant named byQUEEN_PROXY_AUTOPROVISION_TENANT(a slug), with theusersandidentitiesrows written in one transaction. With auto-provision on and no valid tenant slug, the callback answers500. -
Otherwise
403 {"code":"not_provisioned"}.
Auto-provision off is the default, and it is the right default for a closed deployment: an OAuth identity that nobody invited gets nothing.
The authorization matrix
Every broker-bound request is classified into one route class (Endpoints a tenant can reach), and the class is matched against the principal. This table is the whole policy:
| Route class | API key needs | User role needs |
|---|---|---|
produce |
produce |
admin or producer |
consume |
consume |
admin or consumer |
queue admin |
admin |
admin |
read |
read or admin |
any role, including viewer |
gated (streams, traces) |
produce or consume |
any role except viewer |
operator |
never | only a live operator |
blocked |
never | never |
Refusals are 403 {"code":"forbidden"}, except for the operator class, which answers
the same 404 a blocked route gives, so a tenant admin is never told there is a
capability to go looking for.
Note the two asymmetries. An API key with only produce cannot read anything, not even
a queue listing, while a viewer session can read everything on the cluster and write
nothing. And read is open to every role because there is nothing tenant-external
behind it: the read class is the tenant-scoped surface, by construction.
Feature gating happens before authorization: a plan without features.streams gets
403 {"code":"feature_gated"} regardless of the credential.
The operator capability
An operator is a users.is_operator row and QUEEN_PROXY_OPERATOR_ENABLED=true
on the cell. Both, resolved per request. With the flag off, an operator-class route
404s before authentication runs at all, the same answer a hard block gives, with no
lookup and no way in.
A live operator is admin on every cluster on that cell, membership row or not, and is
the only principal that can open the cell-wide surfaces: broker status, buffer state,
host and PostgreSQL metrics, maintenance mode, and the Prometheus endpoint. Operator
access is logged, sampled to one line per minute with a suppressed count, because a
dashboard polls those routes.
Granting it is a psql call to queen_proxy.set_operator(email, true). There is
deliberately no HTTP path. See
Tenants, clusters and cells.
Acting on a cluster the Host header does not name
The dashboard is served from one hostname for every cluster, so a browser has no DNS
label to route by. x-queen-act-cluster supplies the cluster explicitly, as a slug or a
cluster UUID:
| Credential | Result |
|---|---|
| API key | 403. A key is bound to its own cluster |
| No credential | 401 |
Session with a cluster_roles row on the target |
Acts with that row’s role |
| Session without a row | 403 no such cluster, or no role on it |
| Live operator session | Acts as admin on any cluster that exists |
An unresolvable cluster reference and an unauthorized one return the same 403
with the same message. That is deliberate: distinguishing them would turn the header
into a way to enumerate every tenant’s cluster slugs.
Failure reference
| Status | Code | Meaning |
|---|---|---|
| 401 | unauthorized |
Missing, unknown, revoked, expired or malformed credential |
| 403 | forbidden |
Valid credential, insufficient for this route class or cluster |
| 403 | feature_gated |
The plan does not include this feature |
| 403 | cluster_suspended |
Tenant or cluster suspended or deleting |
| 403 | not_provisioned |
OAuth identity with no account, auto-provision off |
| 404 | route_blocked |
Blocked route, or an operator route without a live operator |
| 421 | cluster_unknown |
The Host label matched no cluster |
Bodies are {"error": "<human>", "code": "<machine>"}. The codes are load-bearing:
the SDKs branch on them rather than on message text.
Related
- Quotas and rate limits: the
429contract and what the SDKs do with it. - Isolation: why the operator surfaces are gated the way they are.
- Broker authentication: the broker’s own JWT, which is a different mechanism from this one and lives on the other side of the proxy.