Auth & namespaces
Bring-your-own-key signup, the three credential classes, short-lived bearers for REST/MCP, and the broker-enforced isolation guarantees.
There is no password database and no platform login. Authentication is possession of a NATS credential, verified by the broker when a connection opens with it. Signup is the sole identity origin; everything else is minted from what signup gave you.
Signup — bring your own key
You generate a user nkey locally and send only the public key. No seed, no secret, and no bearer ever travels in the signup exchange — an eavesdropper who captures the reply holds JWTs whose seeds it does not have.
import { createUser } from "nkeys.js";
const owner = createUser();
const pub = owner.getPublicKey(); // U… — this goes to signup
const seed = new TextDecoder().decode(owner.getSeed()); // SU… — stays with youcurl -s -X POST "$BASE/v1/signup" -H "Content-Type: application/json" \
-d "{\"handle\":\"my-space\",\"ownerUserPublicKey\":\"$PUB\"}"The reply carries only public artifacts:
ns— your friendly namespace (allocated if the handle was taken or omitted),accountPublicKey— the namespace's own NATS account,ownerUserJwt(+materialUserJwtif you sent a second public key) — user JWTs bound to your public keys; only your local seed can use them,- per-credential private
inboxPrefixvalues — connect withwsconnect({ inboxPrefix })or request/reply replies are broker-denied (S1).
Deployments can set SIGNUP_MODE=invite, making inviteToken required; the per-source
rate limit answers rate_limited when tripped.
The three credential classes
Each namespace account signs user JWTs with a signing key (the account identity seed is destroyed after mint, A13). Three classes exist, distinguished only by broker grants:
| Class | May publish | May subscribe | Use |
|---|---|---|---|
owner | api.> (everything in the namespace) | evt.> + its private inbox | Full control: apps, material, sessions, realtime. |
material | api.material.>, reads, api.session.token.material | private inbox | The separate write authz handed to LLM tooling: can read and patch material, cannot manage apps or mint owner bearers. |
bridge-<app> | api.rt.<app>.>, api.session.<app>.resume | evt.<app>.> + private inbox | The only browser credential: bearer_token: true, exp ≤ 1h, plus an explicit broker deny on api.material.> and api.app.>. |
Authorization classes on each address in the API reference name the minimum
class: material means material or owner may call; mutations of apps and sessions are
owner-only. Enforcement is the broker permission set minted at signup — never handler code.
Bearers for REST and MCP
REST and MCP calls carry Authorization: Bearer <user-jwt> where the JWT is
bearer_token: true (no nonce signature needed — possession is the credential, so keep
them short-lived). Two mints exist:
- Namespace bearer —
api.session.token.ownerorapi.session.token.material(POST /v1/session/token/owner|material). The scope is a subject token, so the broker decides who can mint what: owner creds reach both; material creds are granted exactly thematerialvariant — a material credential can never escalate to an owner bearer. Default TTL 900 s, capped at 3600 s. - Bridge session —
api.session.<app>.mint(owner only) mints the app-scoped browser bearer the shell connects with;api.session.<app>.resumeis the one call the bridge credential itself may make outsidert.
The bootstrap sequence from nothing (this is exactly what qctl smoke run does):
1. POST /v1/signup (public key only — no auth yet)
2. connect NATS once, natively (wss <domain>/nats, jwtAuthenticator(ownerJwt, seed),
the reply's inboxPrefix)
3. request api.session.token.owner {} → { bearerJwt, exp, inboxPrefix }
4. use bearerJwt for every REST/MCP callThe gateway holds no identity map and no fallback identity: it opens a NATS connection as the bearer's holder and forwards your request; the broker applies the JWT's own grants. REST/MCP permissions are byte-identical to native NATS permissions (A15′).
Isolation guarantees
- Unroutable, not just denied. Namespaces are separate NATS accounts; namespace B's subjects do not exist in namespace A's subject space. The only cross-account paths are the two platform exports, and the broker pins each import to the importing account's own public key (token position 3).
- Identity is never payload. Core reads the caller's account from the subject the
broker routed; app identity for sessions and realtime also lives in the subject
(
api.rt.<app>.intent), inside the bridge grant — a payload cannot name someone else. - Continuously probed.
qctl probe isolationruns a denial matrix (cross-namespace publish/subscribe, bridge writing material, material touching rt, expired bearer connect, JetStream API access) and requires a broker permission violation and zero delivery on every row; broker authorization errors are also counted into a Victoria metric so violations are observable in operation, not only at probe time.