Authentication

How a bearer token becomes a known principal — the request shape, the dispatcher, and every built-in driver.

Running a serverdocs/server/20-authentication.mdx
On this page

Authentication is the step that turns a raw HTTP request (or WebSocket upgrade) into a Principal the rest of the server can reason about. Bubble is intentionally narrow about what authentication does: it produces a BubbleAuthContext carrying a principalId and nothing more. Whether that principal exists as a durable row, what it can do, and how it is represented over the wire are all separate concerns handled later in the request pipeline.

This page covers:

  1. The bearer-token request model and the BubbleAuthContext it produces.
  2. The authenticator dispatcher — how Bubble picks one driver per request from an ordered stack.
  3. Every built-in driver: default, master-key, local-token, auth0, entra.
  4. How to plug in a custom driver.

The Request Model

Every request — HTTP and WebSocket — authenticates using Authorization: Bearer <token>. There is no per-frame WS auth, no cookie path, and no in-band login. The token is read once on the upgrade for WS connections.

A successful authentication produces:

ts
interface BubbleAuthContext {
  principalId: string; // the only required output
  token: string | null; // the original bearer, retained for downstream use
  kind?: 'user' | 'bot' | 'system';
  displayName?: string;
  email?: string;
  avatarUrl?: string;
}

The kind and profile fields are forwarded to clients (notably through the whoami route) when the driver can supply them. If kind is absent the registration step downstream defaults it to 'user'.

Failed authentication throws a BubbleProtocolError(unauthenticated). The dispatcher and every driver share that error shape so transports can render a uniform 401.

The Dispatcher

A real deployment usually has more than one credential type — say, local bot tokens for agents and provider JWTs for humans. The dispatcher is the small piece of plumbing that decides which driver gets to handle a given request.

The dispatcher is configured as an ordered list of entries. Each entry optionally carries a canAuthenticate predicate that inspects the request (typically the bearer token) and returns whether the entry "owns" it:

ts
interface DispatchingBubbleAuthenticatorEntry {
  name?: string; // for diagnostics
  canAuthenticate?: (request: BubbleAuthRequest) => boolean;
  authenticator: BubbleAuthenticator;
}

Three behaviours follow from this design:

  • First-match wins. Entries are evaluated in order. The first entry whose canAuthenticate returns true (or has no predicate at all) handles the request.
  • Failure is final. If the chosen entry rejects the credentials, the dispatcher returns the rejection directly. It does not fall through to a different credential system on failure — that would let a token pretend to be a different shape and probe for which driver finally accepts it.
  • No-match is a 401. When every predicate returns false the dispatcher itself throws unauthenticated.

This makes dispatch a cheap, signature-free routing decision: each driver owns its bearer-token shape, the dispatcher reads the shape and hands the request to the matching driver, and authorization or business logic never sees the dispatcher.

A typical production stack:

json
{
  "auth": [
    { "driver": "local-token" },
    {
      "driver": "auth0",
      "domain": "example.us.auth0.com",
      "audience": "https://bubble.example.com",
      "principalIdClaim": "https://octostaff.example.com/principal_id",
      "kindClaim": "https://octostaff.example.com/kind"
    },
    { "driver": "master-key", "masterKeyEnvVar": "BUBBLE_MASTER_KEY" }
  ]
}

Notice the order: shape-guarded drivers (local-token, auth0, entra) come first; the catch-all (master-key) is last because it has no shape guard and would match any bearer token.

Two JWT drivers can sit in the same stack. Because each guards on its own issuer, an Entra token is never offered to the Auth0 driver and vice versa — dispatch stays a routing decision, not a signature race.

Built-in Drivers

Bubble ships five drivers. Each is a BubbleAuthenticator — implementing authenticate(request) → BubbleAuthContext — and each is also paired with a request-shape predicate the dispatcher uses to route requests to it.

default

The simplest driver. Accepts any request and resolves it to either the bearer token (verbatim) or a configurable fallback principal id.

ts
{ driver: 'default', defaultPrincipalId?: string }
  • Predicate. None — default is always a catch-all and must come last in any stack that includes other drivers.
  • Behaviour. If the request carries a bearer token, the authenticated principal id is that token (so Authorization: Bearer alice resolves to alice). Otherwise the principal id is defaultPrincipalId (defaults to anonymous).

This driver is intended for tests and the zero-config launcher. Do not use it in production — anyone can declare any principal id by setting the bearer to that id.

master-key

A single shared secret that authenticates as a configurable principal, optionally letting the caller impersonate any other principal via a header.

ts
{
  driver: 'master-key',
  masterKey?: string,                        // inline value (avoid)
  masterKeyEnvVar?: string,                  // env var to read; defaults to BUBBLE_MASTER_KEY
  principalIdHeader?: string,                // defaults to "x-bubble-principal-id"
  defaultPrincipalId?: string,               // fallback when the header is absent
}
  • Predicate. None. Place last.
  • Behaviour. If the bearer token equals the configured master key, authentication succeeds. The resulting principalId is the value of the principalIdHeader (after trimming) when present, otherwise defaultPrincipalId. Any other token is rejected.
  • Startup check. The driver throws at construction time if no master key is configured, so a missing BUBBLE_MASTER_KEY fails fast rather than at the first request.

The dev config bundles this driver with a seeded system principal named root so a single key (held in BUBBLE_MASTER_KEY) can drive the whole API as the system owner. In production it is most useful for trusted internal tooling.

Warning. Avoid configuring a master key that contains . characters — its tokens become visually indistinguishable from JWTs, which can confuse later dispatcher entries that do guard by JWT shape.

local-token

The driver behind Bubble-issued bot credentials. Looks the bearer up in the store by hash and returns the bot's principal id.

ts
{
  driver: 'local-token';
}
  • Predicate. isBubbleLocalBearerRequest — true when the bearer token starts with bubble_agent_ (BUBBLE_LOCAL_TOKEN_PREFIX). This is what makes the driver a cheap, shape-guarded entry.
  • Behaviour. The driver hashes the bearer (SHA-256), looks the hash up in BubbleStore.findPrincipalByTokenHash, and returns the bot principal that the row points at. It also calls recordPrincipalTokenUse(tokenId) for observability — that write is fire-and-forget and never propagates failure to the auth path (otherwise an observability outage could turn a valid token into a 401).

How tokens are minted and rotated lives in Principals.

auth0

JWT verification against an Auth0 (or any Auth0-compatible) tenant. Validates issuer, audience, expiry, and signature against a cached JWKS, then projects standard or custom claims into a Principal.

ts
{
  driver: 'auth0',
  domain?: string,                           // tenant domain, e.g. "example.us.auth0.com"
  issuer?: string,                           // defaults to `https://${domain}/`
  audience?: string,                         // expected `aud`; recommended in production
  clientId?: string | { env: string },        // public Native app client id for terminal device login
  scopes?: string[],                         // defaults to openid, profile, email, offline_access
  jwksUri?: string,                          // defaults to `${issuer}.well-known/jwks.json`
  principalIdClaim?: string,                 // defaults to "sub"
  kindClaim?: string,                        // optional; values: "user" | "bot" | "system"
  defaultKind?: 'user' | 'bot' | 'system',   // when kindClaim is absent; defaults to "user"
  displayNameClaim?: string,                 // defaults to "name"
  emailClaim?: string,                       // defaults to "email"
  avatarUrlClaim?: string,                   // defaults to "picture"
  clockToleranceSeconds?: number,            // defaults to 60
  jwksCacheTtlMs?: number,                   // defaults to 5 minutes
}
  • Predicate. isJwtIssuerRequest(request, issuer) — the dispatcher decodes the bearer as a JWT and matches its iss claim against the configured issuer.
  • Required config. Either domain or issuer must be set, and the schema rejects entries without either. This keeps dispatch shape-based: an unguarded "looks like a JWT" entry would let any JWT reach the driver and fail with a signature error rather than routing past it.
  • Validation rules. RS256 only; the kid must be present in the cached JWKS; iss must equal the configured issuer; aud must match when configured; exp and nbf are checked with clockToleranceSeconds of slack.
  • Claims projection. principalId comes from principalIdClaim (default sub). kind comes from kindClaim if present and is validated to be one of user / bot / system; otherwise defaultKind is used. displayName comes from displayNameClaim when present, email comes from emailClaim, and avatarUrl comes from avatarUrlClaim.

JWKS responses are cached for jwksCacheTtlMs (default 5 minutes); a new kid triggers a cache refresh on the next request.

To project custom values into principalIdClaim / kindClaim / displayNameClaim / emailClaim / avatarUrlClaim, install an Auth0 post-login Action that writes the matching custom claims onto the access token. A working sample lives at examples/auth0-post-login-action.js.

Terminal device login setup

The terminal client discovers its Auth0 settings from Bubble. Use a Native application for the TUI, separate from Starfish's Regular Web Application. Both applications use the same tenant and Bubble API audience. Starfish keeps its own client ID and secret in its environment; Bubble's auth0.clientId is the Native application's public client ID. The Native app's client secret is never used by the TUI, including for device authorization, token polling, and refresh.

In the Auth0 dashboard:

  1. Under Applications → APIs, select the Bubble API, or create it with an Identifier matching Bubble's audience and RS256 signing. In its settings, enable Allow Offline Access so offline_access can yield refresh tokens.
  2. Under Applications → Applications, create a Native application for the terminal client. Set Token Endpoint Authentication Method to None.
  3. Under the Native application's Advanced Settings → Grant Types, enable Device Code and Refresh Token. Enable a login connection for this app under Connections.

These are Auth0's device-flow prerequisites.

Add the Native client ID to the existing Bubble auth0 entry, preserving any custom claim mappings:

json
{
  "auth": [
    { "driver": "local-token" },
    {
      "driver": "auth0",
      "domain": "example.us.auth0.com",
      "audience": "https://bubble.example.com",
      "clientId": { "env": "AUTH0_TUI_CLIENT_ID" },
      "scopes": ["openid", "profile", "email", "offline_access"]
    }
  ]
}

Set AUTH0_TUI_CLIENT_ID in Bubble's environment to the Native app's client ID and restart Bubble. A literal client ID is also accepted; it is public. scopes may be omitted to use the values shown above. If you override them, keep offline_access for refresh. The TUI has no separate Auth0 environment settings: it reads issuer, audience, clientId, and scopes from GET /v1/auth/authenticators.

Verify discovery and start a login:

sh
curl --fail https://bubble.example.com/v1/auth/authenticators
bubble-tui --base-url https://bubble.example.com login --driver auth0

The discovery response's auth0 entry should contain the Native client ID, the tenant issuer, the Bubble audience, and offline_access in its scopes. Open the URL printed by login and authorize the displayed code. Successful login prints your principal ID and saves credentials for that profile and server. You can also choose auth0 in the interactive login picker.

The client replaces stored tokens after each refresh, so it supports Refresh Token Rotation when enabled on the Native app. If no refresh token was issued, check the API's Allow Offline Access setting, the app's Refresh Token grant, and the discovered scopes, then log in again. If device login cannot start, check that discovery contains the Native client ID, Device Code is enabled, and the authentication method is None. Using Starfish's client ID here will break terminal login.

See the terminal quickstart and credential reference for profile selection and saved-login behavior.

entra

JWT verification against a single Microsoft Entra ID (formerly Azure AD) tenant. Same machinery as auth0 — issuer, audience, expiry, and an RS256 signature checked against a cached JWKS — plus two checks that are specific to Entra.

ts
{
  driver: 'entra',
  tenantId: string,                          // required; directory (tenant) ID
  clientId: string,                          // required; app registration that exposes the Bubble API
  requiredScope?: string,                    // required in `scp`; defaults to "access_as_user"
  scopes?: string[],                         // defaults to [`api://${clientId}/${requiredScope}`]
  authorityHost?: string,                    // defaults to "https://login.microsoftonline.com"
  issuers?: string[],                        // defaults to the tenant's v2.0 and v1.0 issuers
  jwksUri?: string,                          // defaults to the tenant's v2.0 discovery keys
  principalIdClaim?: string,                 // defaults to "oid"
  kindClaim?: string,                        // optional; values: "user" | "bot" | "system"
  defaultKind?: 'user' | 'bot' | 'system',   // when kindClaim is absent; defaults to "user"
  displayNameClaim?: string,                 // defaults to "name"
  emailClaim?: string,                       // defaults to the UPN: "preferred_username", else "upn"
  avatarUrlClaim?: string,                   // unset by default
  clockToleranceSeconds?: number,            // defaults to 60
  jwksCacheTtlMs?: number,                   // defaults to 5 minutes
}
  • Predicate. isEntraJwtRequest(request, issuers) — the dispatcher decodes the bearer and matches iss against the tenant's issuers. Both are accepted, because which one Entra stamps follows the app registration's accessTokenAcceptedVersion rather than anything the client does: https://login.microsoftonline.com/<tenant>/v2.0 and https://sts.windows.net/<tenant>/.
  • Required config. tenantId and clientId. The audience is derived from clientId and is not separately configurable. This makes a token addressed to this Bubble rather than to any other application in the directory.
  • Both audience forms are accepted, for the same reason both issuers are: a v2.0 access token carries the API's client-ID GUID in aud, while a v1.0 one carries the resource identifier the client requested, api://<client-id> for the default Application ID URI. Which one a registration mints follows its requestedAccessTokenVersion, and the deployment has no say in it, so clientId and api://<client-id> are both accepted. Both name the same registration, so this does not admit another application.
  • Audience and scope URI are distinct. The requested scope always uses an Application ID URI, such as api://<client-id>/access_as_user, whichever form the audience takes. A custom Application ID URI requires explicit scopes; a deployment using one and issuing v1.0 tokens also needs explicit issuers, since its aud is derived from neither the client id nor the default URI.
  • The credential is an access token, not an ID token. The app registration exposes an API scope, the browser requests it, and the driver requires both the matching aud and that scope's presence in scp. When the SPA and API share an app registration, their ID and v2 access tokens can share an audience; the delegated scope is still required, so an ID token is rejected. A Microsoft Graph token is addressed to Microsoft, and Bubble cannot meaningfully validate it.
  • requiredScope is yours to name. It defaults to Microsoft's sample name access_as_user, but the exposed scope's name is set on the app registration; set this to whatever that is.
  • Single tenant by design. tid is pinned to tenantId. A multi-tenant registration mints tokens carrying the same audience for every directory that consents, so without this check a foreign directory's user would map onto a principal here.
  • Claims projection. principalId comes from principalIdClaim, which defaults to oid — the user's immutable per-tenant object id, which survives a UPN or display-name change. Because tid is pinned, oid alone is unambiguous for the deployment. Use preferred_username instead only when readable principal ids matter more than surviving a rename. email defaults to the UPN, read from whichever claim carries it: preferred_username in a v2.0 token, upn in a v1.0 one, which has no preferred_username. Setting emailClaim pins it to that one claim. Entra omits email from access tokens unless the registration adds it as an optional claim, and publishes no picture claim at all, so avatarUrlClaim is unset.

What the client is told

This driver publishes clientId and scopes through GET /v1/auth/authenticators for Starfish's MSAL login. The auth0 driver publishes these fields for its Native terminal client instead. Both are public values, and publishing them is what lets a web client configure its MSAL instance from the server it is already talking to instead of baking one deployment's identity provider into its build — so one Starfish image can serve tenants that differ.

App registration

  • Authentication → Single-page application, with redirect URI https://<starfish-origin>/auth/entra/callback and post-logout redirect URI https://<starfish-origin>/login. Leave the implicit grant checkboxes off: the flow is authorization code + PKCE.
  • Expose an API, with an Application ID URI (api://<client-id> by default) and a delegated scope whose name matches requiredScope.
  • No Microsoft Graph permission is required. The login path requests the API scope only and calls Graph nowhere; the signed-in user's name and email come from the access token's own claims.
  • No client secret. Starfish is a public client and holds none. Reusing an App Service sign-in adds one that only App Service holds.

Reusing an App Service sign-in

When Azure App Service authentication ("Easy Auth") gates Starfish, the user has already signed in to Entra ID before the page loads. Starfish then sends Bubble the access token held in App Service's token store instead of asking for a second Microsoft sign-in. Nothing in Starfish is configured for this: on an entra deployment it asks /.auth/me on each page load, and without Easy Auth that request gets Starfish's own 404, so the MSAL login above runs unchanged. Bubble needs no change either — the stored token is an ordinary Entra access token, and this driver validates it like any other.

  • Gate Starfish only. Bubble's App Service must pass unauthenticated requests through to Bubble. Authenticator discovery and the WebSocket upgrade carry no credential by design, and a cross-origin fetch or WebSocket upgrade from Starfish cannot follow a login redirect. Bubble stays protected by this driver.
  • Reuse the same app registration. Add a Web platform redirect URI https://<starfish-origin>/.auth/login/aad/callback and a client secret to the registration that exposes Bubble's API, so the stored token's aud is already Bubble's. App Service is a confidential client, and its token store holds access tokens only when a secret is configured. Keep the single-page application entry: it is the fallback.

Configure the Microsoft provider with the secret in an app setting (a Key Vault reference works), then require sign-in with the token store on:

bash
az webapp auth microsoft update -g <rg> -n <starfish-app> \
  --client-id <client-id> --tenant-id <tenant-id> \
  --client-secret-setting-name MICROSOFT_PROVIDER_AUTHENTICATION_SECRET
az webapp auth update -g <rg> -n <starfish-app> \
  --enabled true --action RedirectToLoginPage \
  --redirect-provider azureactivedirectory --enable-token-store true

Then make the provider request Bubble's API scope. az webapp auth update --set cannot write this value — it splits its argument at every = — so edit the settings resource directly:

bash
auth="$(az webapp show -g <rg> -n <starfish-app> --query id -o tsv)/config/authsettingsV2"
az rest --method get --url "$auth/list?api-version=2020-12-01" \
  | jq '{properties: (.properties
      | .identityProviders.azureActiveDirectory.login.loginParameters =
          ["scope=openid profile email offline_access api://<client-id>/access_as_user"])}' \
  > authsettings.json
az rest --method put --url "$auth?api-version=2020-12-01" --body @authsettings.json

loginParameters is the setting everything rests on. Without Bubble's scope the token store holds a Microsoft Graph token that Bubble rejects; without offline_access there is no refresh token, and the user is sent back through the platform login whenever the access token expires. Starfish checks the stored token's aud and scp before using it. A token it cannot use is reported in the browser console with the setting to fix, and Starfish falls back to the MSAL login.

Renewal and logout go through the platform too: Starfish calls /.auth/refresh shortly before the token expires, and Log out navigates to /.auth/logout. With sign-in required, the page that logout lands on prompts again straight away. That is the gate working, not a failed logout.

Custom Drivers

A custom driver is anything that implements BubbleAuthenticator:

ts
interface BubbleAuthenticator {
  authenticate: (request: BubbleAuthRequest) => Promise<BubbleAuthContext> | BubbleAuthContext;
}

Use the helpers in @octostaff/bubble to keep the bearer parsing consistent:

ts
import {
  type BubbleAuthenticator,
  parseBearerToken,
  createDispatchingBubbleAuthenticator,
  BubbleErrorCode,
  BubbleProtocolError,
} from '@octostaff/bubble';

const myDriver: BubbleAuthenticator = {
  authenticate(request) {
    const token = parseBearerToken(request.authorization);
    if (token !== process.env.MY_SHARED_KEY) {
      throw new BubbleProtocolError(BubbleErrorCode.Unauthenticated, 'bad token');
    }
    return { principalId: 'ops-bot', token, kind: 'bot' };
  },
};

const authenticator = createDispatchingBubbleAuthenticator([
  {
    name: 'mine',
    canAuthenticate: (req) => req.authorization?.startsWith('Bearer ops_') ?? false,
    authenticator: myDriver,
  },
]);

Two contracts to honour when writing a driver:

  • Throw BubbleProtocolError(unauthenticated) on any failure — bad shape, missing token, bad signature, expired credential. The transports translate that into a uniform 401 / WS error frame.
  • Do not block the auth path on best-effort writes. If your driver records anything for observability, swallow transient errors the way local-token does. A logging failure must not turn a valid credential into a rejection.

The dispatcher has no opinion about how many entries you stack — the production config above uses three; a custom one might use one. The order rule is the same: shape-guarded entries first, catch-alls last.

What Happens Next

Once the dispatcher returns a BubbleAuthContext, the server hands it to the registration step. That step decides whether the resolved identity becomes a durable principal row, whether it picks up a self-grant, and (for the very first user) whether it inherits the seeded system owner role. The full sequence lives in Bootstrapping.