Authentication
Inbox stores no users and issues no credentials of its own. Axis Accounts is the source of truth for both. Every credential that reaches inbox is either something Accounts minted (a service key, a user session) or something inbox derived from one (a realtime token, an agent-run token, a widget token).
AuthGuard resolves whatever arrived into an AuthContext carrying { mode, appId, scopes } plus,
for a user session, user: { userId, sessionId, accountsTenantId, isOperator }. Everything downstream —
tenant resolution, scope enforcement, idempotency, event attribution — reads that one object.
The credential types
| Credential | Header | Resolves to | Scopes |
|---|---|---|---|
| Accounts service key | x-api-key: sk_… or Authorization: Bearer sk_… |
app + keyId + accessibleApps, no user |
whatever Accounts granted |
| Accounts user session | Authorization: Bearer <sessionId> |
a real userId + sessionId |
fixed set, never admin |
| Realtime token | Authorization: Bearer <token> on @RealtimeControl() routes; Sec-WebSocket-Protocol on the WS upgrade |
app + tenant, aud: realtime |
copied from the minting credential |
| Agent-run token | presented to intelligence, not to inbox | one agent + tenant/thread, aud: agent-run |
none |
| LiveChat widget token | Sec-WebSocket-Protocol on the WS upgrade |
one thread + one visitor | copied, but thread-filtered |
Accounts service key (sk_)
A machine credential. It acts as the whole tenant, carries no identity, and is the only way to reach
the admin surface. Inbox does not validate the key itself — it introspects it remotely against
Accounts through @~lyre/auth, and caches the answer for INBOX_ACCOUNTS_CACHE_TTL_MS (default
60000). A revoked key therefore keeps working for up to a minute.
Accounts scopes arrive namespaced (inbox:threads, inbox:publish, …) and are mapped onto inbox's
local vocabulary by toLocalScopes. Scopes belonging to other apps are ignored, not rejected — a
key may legitimately span several apps.
The key may grant several apps. resolveLocalAppId mirrors them into the local app table (so the
Tenant.appId foreign key is satisfiable) and picks the one named by the service's configured app
slug, falling back to the first. If nothing resolves you get invalid_api_key, not a 500.
The legacy axs_ key format is gone. The SDK constructor rejects anything not starting sk_
before a request is ever made:
Invalid appKey: expected an Axis Accounts service key (sk_…)
The package READMEs still show axs_ and a required tenant. They are stale; the constructor is
authoritative.
Accounts user session
Any bearer that is not an sk_ key is tried as an Accounts session id first, then as a realtime
token. A session that resolves gets a fixed local scope set, mapped from:
inbox:connections inbox:threads inbox:publish inbox:stats
admin is deliberately excluded. A session must not reach import/migration, tenant merge, contact
merge, outbound-webhook administration or native-claim release — break-glass operations that need a
machine key someone had to deliberately provision, not a browser cookie someone could be phished out
of. stats is granted to every session, because the tenant-scoped /v1/reports/* routes require
it and reading your own workspace's aggregates is an ordinary member action. That one scope therefore
guards two trust levels, which is why OperatorGuard adds a second check.
A session also resolves isOperator — true when Accounts reports the user as a member of the app
named by INBOX_OPERATOR_APP_SLUG. That membership list comes from GET /api/auth/me/apps, cached
on the same TTL and keyed by bearer. Empty responses are never cached: a partial upstream outage
would otherwise pin "this user belongs to nothing" for a full minute and silently strip operator
access.
Realtime token
Minted by POST /v1/realtime/tokens (a write — it needs an Idempotency-Key). The response is
{ token, expiresIn, wsUrl: "/v1/realtime" }.
The format is base64url(payload).base64url(sig), HMAC-SHA256 over the payload segment. It is not
a JWT — there is no header segment and no alg negotiation, which is the point: there is nothing
to downgrade. Verification checks the signature with timingSafeEqualString, then aud === "realtime",
mode === "realtime", and exp. TTL is INBOX_REALTIME_JWT_EXPIRES_IN seconds, default 600.
The payload carries sub (appId), tenantId, scopes, jti, iat, exp, and optionally userId
so the gateway can deliver a user-targeted event (a session.revoked, say) to exactly one operator's
sockets.
Agent-run token
Same wire format, different secret and different audience. aud is agent-run and the secret is
INBOX_AGENT_RUN_TOKEN_SECRET, shared with the intelligence service. A token minted for one audience
can never be replayed on the other. TTL is INBOX_AGENT_RUN_TOKEN_TTL seconds, default 900. The
feature is off when no secret is configured — minting returns null rather than throwing.
LiveChat widget token
Minted by the public POST /v1/livechat/:widgetKey/session with a 300-second TTL. It is a realtime
token that additionally carries threadId and visitorId. The gateway subscribes it to the tenant
bus and then drops every event that is not for that thread. Without that filter an anonymous
website visitor's token would stream the entire workspace's conversations.
What a machine key cannot do
Some routes need a person, not a tenant. A machine key carries no user, so:
- ticket assign/reassign refuses with
validation_failed(422) — "Assignment requires a user session; a service key cannot validate an assignee." Assignees are validated against Accounts group membership, and a key has nothing to validate against. Writing an unvalidated Accounts UUID would produce a ticket assigned to someone inbox can never resolve, and nothing downstream would notice. - the per-user interaction routes (mark-read, reactions, likes) refuse the same way.
422 rather than 403 is deliberate: nothing about the credential is wrong, the request simply cannot be attributed.
Worked example
curl -sS https://inbox.example.com/v1/inbox/threads \
-H 'x-api-key: sk_live_…' \
-H 'x-axis-tenant: ws_01J8Z…'
A write adds an idempotency key (see Idempotency):
curl -sS -X POST https://inbox.example.com/v1/inbox/threads/thr_123/reply \
-H 'x-api-key: sk_live_…' \
-H 'x-axis-tenant: ws_01J8Z…' \
-H 'idempotency-key: 4d1f2c9a-6b0e-4a1d-9c3f-0f2a7b5e8d10' \
-H 'content-type: application/json' \
-d '{"message":"On it — checking now."}'
Smoke test
GET /v1/whoami echoes back exactly what the pipeline resolved. It is the fastest way to tell a bad
key from a bad tenant from a missing scope.
curl -sS https://inbox.example.com/v1/whoami \
-H 'authorization: Bearer sk_live_…' \
-H 'x-axis-tenant: ws_01J8Z…'
{
"data": {
"appId": "app_01J…",
"mode": "bearer",
"scopes": ["connections", "inbox", "publish", "stats"],
"tenantId": "tn_01J…"
},
"meta": { "requestId": "…" }
}
If tenantId comes back for a workspace you did not expect, stop. Tenant resolution upserts, so a
wrong x-axis-tenant silently provisions an empty workspace rather than failing.
GET /v1/ping and GET /v1/health are @Public() and need no credential at all.