---
title: "Credentials and authorization"
description: "Two credential kinds (cluster API keys and proxy-minted user sessions), the OAuth login flow, and the authorization matrix."
---

> Documentation Index
> Fetch the complete documentation index at: https://queenmq.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Credentials and authorization

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](/selfhost/multi-tenant/proxy).

## How a credential is read

From the `Authorization` header first, then, for some surfaces, from the session
cookie:

1. `Authorization` is read and a `Bearer ` prefix stripped. A value that starts with
   `qk_` is an API key; anything else is treated as a session token.

2. If there is no usable `Authorization` header, the session cookie is considered.
   An explicit header always wins over the cookie: a token is chosen per request,
   a cookie merely rides along.

3. 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 → `403` with `key/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

```text
queen_session=<jwt>; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400
```

`Domain` 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
  validated `next` and a 300-second expiry. The key comes from
  `QUEEN_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's `state`. Set the
  variable on any auth host that is not a single process.
- **`next` is 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_verified` and 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 from `X-Forwarded-For` (first entry) or `X-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

1. A linked `identities` row for this `(provider, provider_id)` → log in as that user.

2. Otherwise, a verified email matching an existing `users` row → link the identity to
   that user and log in.

3. Otherwise, auto-provision, but **only** when `QUEEN_PROXY_AUTOPROVISION=true`. The new
   user is created under the tenant named by `QUEEN_PROXY_AUTOPROVISION_TENANT` (a
   slug), with the `users` and `identities` rows written in one transaction. With
   auto-provision on and no valid tenant slug, the callback answers `500`.

4. 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](/selfhost/multi-tenant/endpoints)), 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
`404`s 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](/selfhost/multi-tenant/tenants).

> **Caution**
>
> Leave `QUEEN_PROXY_OPERATOR_ENABLED` off on every cell that carries other people's
> clusters. With it off, the cell-wide routes are structurally unreachable: there is no
> code path to them, so there is no bug that can open one.

## 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](/selfhost/multi-tenant/quotas): the `429` contract and
  what the SDKs do with it.
- [Isolation](/selfhost/multi-tenant/isolation): why the operator surfaces are gated
  the way they are.
- [Broker authentication](/selfhost/security/jwt): the broker's own JWT, which is a
  different mechanism from this one and lives on the other side of the proxy.

Source: https://queenmq.com/selfhost/multi-tenant/auth/index.mdx
