Authentication planes
Five credential types, deliberately separated. The rule that matters: only the room-scoped session token and the directory session ever reach an end-user device.
| Credential | Reaches | Scope | Client-safe? |
|---|---|---|---|
ak_… server key | /v1/server/* | One tenant + environment, scope-gated | No |
as_… account session | /v1/account/* | One customer account (billing, keys, apps) | No |
| Operator HMAC | /internal/v1/* | Whole deployment | Never |
du_… directory session | /v1/directory/* | One end-user in one tenant | Yes |
| Session token (JWT) | /v1/rooms/*, both sockets, E2EE, push | One principal, one room, minutes | Yes |
Room-scoped session tokens #
The credential your app actually holds. Signed with Ed25519, it authorises one principal, in one room, for a limited time, and carries granular permissions (read history, send, publish media, subscribe to media).
- Default lifetime 600 s; maximum 1 h. Build re-minting into your client from day one.
- Signing keys rotate with overlap, so a rotation never invalidates tokens already in flight.
device_iddistinguishes the same user's devices — required if one person should be able to join a call from a phone and a laptop simultaneously.- Mint via
POST /v1/server/session-tokens(Path A) orGET /v1/directory/conversations/{room_id}/token(Path B). Both return the same bundle: token, expiry, both socket URLs, and ICE servers.
Presenting it
REST calls use a normal bearer header. WebSockets use the subprotocol — never the query string:
Sec-WebSocket-Protocol: chatbox, bearer.<access_token>
Putting a credential in a URL leaks it into access logs, proxy logs and Referer
headers. The subprotocol is the reason this API does not accept ?token=.
Customer server keys (ak_) #
Your backend's credential for /v1/server/*: provision rooms, manage members, mint
tokens, send as a principal, read usage, manage webhooks.
- Tenant-pinned. The tenant comes from the key. A
tenant_idin a request body is rejected, not honoured. - Scope-gated. Keys carry scopes. Issue the narrowest set that works — but
know what the default is (below), because a missing scope is a
403at call time, not an error at key creation. - Environment-isolated. A
testkey cannot seelivedata. - Rotatable.
POST /v1/account/api-keys/{key_id}/rotateswaps the secret atomically; revoke withDELETE. - Verified in constant time, and rate-limited per key.
Scopes — and the default you probably don't want
POST /v1/account/api-keys requires environment and accepts an optional
scopes array. Omit scopes and you get a deliberately narrow
default that is missing several things a real backend needs:
| Scope | Needed for | In the default? |
|---|---|---|
rooms:read / rooms:write | Provision and read rooms | Yes |
members:write | Add / remove members | Yes |
tokens:mint | Mint client session tokens | Yes |
messages:write | Server-side sends | Yes |
usage:read | GET /v1/server/usage | No |
webhooks:manage | All /v1/server/webhooks routes | No |
rooms:delete | DELETE /v1/server/rooms/{id} (erasure) | No |
data:erase | Principal erasure | No |
403 forbidden: missing scope webhooks:manage. The fix is to
re-create (or rotate) the key with the scopes listed explicitly — scopes are set at creation.
ak_ key ever ships inside a mobile app, treat it as public.
Rotate it and move token minting server-side, or switch to the directory path where clients are
supposed to talk to us directly.
Account sessions (as_) #
The self-serve control surface: sign up, log in, manage API keys, provision directory apps and set their push credentials. Opaque bearer, hashed at rest, 30-day lifetime. This is a dashboard credential — it belongs in your admin tooling, not in a product build.
Directory sessions (du_) #
The end-user credential on the turnkey path, obtained by verifying a phone number. Opaque, hashed at rest, long-lived, swept when expired. It authorises the directory surface for exactly one user — profile, contacts, conversations, devices — and is the thing you exchange for room tokens.
Operator HMAC (/internal/v1/*) #
The deployment-wide control plane, used by whoever runs the infrastructure. Requests are signed over method, path, body digest, timestamp and a single-use nonce:
x-chatbox-key-id: k1
x-chatbox-timestamp: 1757500000
x-chatbox-nonce: 3f2b… # single-use, within the time window
x-chatbox-signature: <hex HMAC-SHA256 over method, path, body digest, timestamp, nonce>
Replays are refused inside the time window, at most two signing keys are active at once (so
rotation is possible without a gap), and the plane is rate-limited per key. If you are integrating
as a customer you should never need this — everything you need is on /v1/server/*.
Getting it right #
- Clients hold only session tokens or
du_sessions. - Re-mint on expiry; a
4401socket close means "token expired, get another". - Never move the tenant into a request body — it comes from the credential.
- Use
testkeys for staging so a mistake cannot touch live rooms. - Cross-tenant access is refused server-side; do not rely on client-side scoping, and do test that it is refused.