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:

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.