Browser apps and the BFF pattern
A browser must never hold a service key, and must never reach Inbox directly. An sk_ key acts
as the whole tenant; shipping one to a page hands every visitor the workspace. Put a server between
them — a backend-for-frontend that holds the credential, resolves the tenant, and exposes only the
routes your UI actually needs.
axis-client is the reference implementation. It proxies 156 inbox routes and every one goes
through a single chokepoint.
One chokepoint, not one client per route
export default defineAxisSdkHandler((event, axis) => axis.connections.list())
defineAxisSdkHandler wraps every inbox BFF route. In order it:
- Reads the caller's Accounts session from the sealed app cookie.
- 401s if there is none — no fallback.
- Resolves the tenant from that session.
- Builds an
Inboxclient bound to{ session, tenant }. - Runs your handler.
- Maps any
InboxErrorto the app's own error envelope, preserving the upstream status.
Centralising it is the point. A per-route client is a per-route opportunity to forget the tenant, or to reach for a machine key when the session lookup is inconvenient.
Session-only, by policy
axis-client forwards the caller's Accounts session as the bearer and never a machine key. Inbox
resolves the session to { userId, sessionId }, so connection ownership, ticket assignment and
group-scoped visibility attribute to the real person on every request.
There is deliberately no machine-key fallback. A machine key knows the workspace but not the user, so falling back to it silently de-identifies the operator — a request with no session becomes a 401 (which the client turns into a login redirect), not a degraded call. Some things simply refuse to work without it: assigning a ticket from a service key returns
422 validation_failed — Assignment requires a user session; a service key cannot validate an assignee.
The BFF also treats a broker auth rejection as session death. When the broker cannot resolve the
forwarded session it answers 401 invalid_jwt or missing_credentials; the handler kills the local
sealed cookie so the app stops claiming the user is signed in. Without this the app sits
half-authenticated — /api/auth/me answers 200 from the cookie while every inbox call 401s. It is
guarded by session age, because a 401 in the seconds right after login is the known cookie-set race,
not a revocation.
Caching: the tenant must be in the key
export default defineCachedAxisSdkHandler(
(event, axis) => axis.reports.inbox(range),
{ ttlSeconds: 30, shouldCache: (event) => !isReportExport(event) },
)
defineCachedAxisSdkHandler resolves the operator exactly as above, then serves from a short-TTL
cache keyed:
axissdk:<resolvedTenant>:<path>?<sorted-query>
A cache keyed on path alone leaks across workspaces. Two users of two different workspaces hit
/api/inbox/threads; the first response is cached under that path and served to the second. That is
not a stale read, it is a tenancy breach — one workspace's conversations rendered in another's UI.
This is also why the framework's own defineCachedEventHandler was not used. Its getKey runs
before the handler, and the tenant is resolved asynchronously from the Accounts session (and
honours a ?tenant= override), so getKey cannot see it. Resolve auth first, then key the cache.
The tenant id is a stable, non-secret workspace identifier; the bearer never enters the key.
Rules that come with it:
- Only GET/read routes. A mutation must never be wrapped.
- The key includes the full path and sorted query, so different filters and pages cache separately.
- Keep the TTL to seconds. Nothing invalidates on write; staleness expires.
- A cache read or write failure is non-fatal — fall through to a live call.
- Give the caller an escape hatch (
shouldCache) for branches that stream a blob and set their own response, such as a CSV export.
Forward the browser's Idempotency-Key
export function getIdempotencyOpts(event: H3Event): { idempotencyKey?: string } | undefined {
const key = getHeader(event, 'idempotency-key')?.trim()
return key ? { idempotencyKey: key } : undefined
}
If the BFF lets the SDK generate a fresh key, a double-clicked Send button becomes two writes: two HTTP requests, two generated UUIDs, two replies. Have the browser mint one key per user action and forward it, and the second request replays the first's response.
A minimal route
// server/api/inbox/threads/[id]/reply.post.ts
export default defineAxisSdkHandler(async (event, axis) => {
const id = getRouterParam(event, 'id')!
const body = await readBody<{ message: string }>(event)
return axis.inbox.threads.reply(id, { message: body.message }, getIdempotencyOpts(event))
})
The browser calls /api/inbox/threads/:id/reply. It never learns the inbox base URL, never holds a
credential, and cannot address a workspace it does not belong to.
Filters belong upstream
The dashboard's operator BFF keeps an allowlist of query keys it forwards, and coerces numbers
and booleans before passing them on. Two reasons: a filter has to reach the service to constrain the
whole dataset rather than the page you already fetched, and an allowlist is what stops an
arbitrary client parameter being forwarded upstream. Passing "1" where the service expects a
number silently skips the filter.
The contrast: the chat widget
axis-chat-widget does not use the SDK, does not have a BFF, and holds no credential. It calls
the public routes with plain fetch:
POST /v1/livechat/:widgetKey/messages
POST /v1/livechat/:widgetKey/session
POST /v1/livechat/:widgetKey/sse/init
GET /v1/livechat/:widgetKey/config
POST /v1/livechat/:widgetKey/conversation
GET /v1/livechat/:widgetKey/conversation/:visitorId/messages
A browser on a customer's marketing site cannot hold a service key, so these routes are unauthenticated by necessity. The widget key in the path identifies which widget, and the connection it resolves to supplies the tenant — nothing there trusts the caller for tenancy. The visitor receives replies over a thread-scoped realtime token rather than a tenant-wide one, so a widget can never see another visitor's conversation.
The difference in one line: a BFF exists because the caller has a credential that must not reach the browser. The widget needs no BFF because there is no credential to protect. See Public surface.