quickable

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 you
curl -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 (+ materialUserJwt if you sent a second public key) — user JWTs bound to your public keys; only your local seed can use them,
  • per-credential private inboxPrefix values — connect with wsconnect({ 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:

ClassMay publishMay subscribeUse
ownerapi.> (everything in the namespace)evt.> + its private inboxFull control: apps, material, sessions, realtime.
materialapi.material.>, reads, api.session.token.materialprivate inboxThe 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>.resumeevt.<app>.> + private inboxThe 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 bearerapi.session.token.owner or api.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 the material variant — a material credential can never escalate to an owner bearer. Default TTL 900 s, capped at 3600 s.
  • Bridge sessionapi.session.<app>.mint (owner only) mints the app-scoped browser bearer the shell connects with; api.session.<app>.resume is the one call the bridge credential itself may make outside rt.

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 call

The 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 isolation runs 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.

On this page