Authentication
How a bearer token becomes a known principal — the request shape, the dispatcher, and every built-in driver.
docs/server/20-authentication.mdxOn 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:
- The bearer-token request model and the
BubbleAuthContextit produces. - The authenticator dispatcher — how Bubble picks one driver per request from an ordered stack.
- Every built-in driver:
default,master-key,local-token,auth0,entra. - 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:
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:
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
canAuthenticatereturnstrue(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
falsethe dispatcher itself throwsunauthenticated.
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:
{
"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.
{ driver: 'default', defaultPrincipalId?: string }- Predicate. None —
defaultis 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 aliceresolves toalice). Otherwise the principal id isdefaultPrincipalId(defaults toanonymous).
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.
{
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
principalIdis the value of theprincipalIdHeader(after trimming) when present, otherwisedefaultPrincipalId. Any other token is rejected. - Startup check. The driver throws at construction time if no
master key is configured, so a missing
BUBBLE_MASTER_KEYfails 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.
{
driver: 'local-token';
}- Predicate.
isBubbleLocalBearerRequest— true when the bearer token starts withbubble_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 callsrecordPrincipalTokenUse(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.
{
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 itsissclaim against the configured issuer. - Required config. Either
domainorissuermust 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.
RS256only; the kid must be present in the cached JWKS;issmust equal the configured issuer;audmust match when configured;expandnbfare checked withclockToleranceSecondsof slack. - Claims projection.
principalIdcomes fromprincipalIdClaim(defaultsub).kindcomes fromkindClaimif present and is validated to be one ofuser/bot/system; otherwisedefaultKindis used.displayNamecomes fromdisplayNameClaimwhen present,emailcomes fromemailClaim, andavatarUrlcomes fromavatarUrlClaim.
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:
- Under Applications → APIs, select the Bubble API, or create it with an
Identifier matching Bubble's
audienceand RS256 signing. In its settings, enable Allow Offline Access sooffline_accesscan yield refresh tokens. - Under Applications → Applications, create a Native application for the terminal client. Set Token Endpoint Authentication Method to None.
- 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:
{
"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:
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.
{
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 matchesissagainst the tenant's issuers. Both are accepted, because which one Entra stamps follows the app registration'saccessTokenAcceptedVersionrather than anything the client does:https://login.microsoftonline.com/<tenant>/v2.0andhttps://sts.windows.net/<tenant>/. - Required config.
tenantIdandclientId. The audience is derived fromclientIdand 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 itsrequestedAccessTokenVersion, and the deployment has no say in it, soclientIdandapi://<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 explicitscopes; a deployment using one and issuing v1.0 tokens also needs explicitissuers, since itsaudis 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
audand that scope's presence inscp. 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. requiredScopeis yours to name. It defaults to Microsoft's sample nameaccess_as_user, but the exposed scope's name is set on the app registration; set this to whatever that is.- Single tenant by design.
tidis pinned totenantId. 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.
principalIdcomes fromprincipalIdClaim, which defaults tooid— the user's immutable per-tenant object id, which survives a UPN or display-name change. Becausetidis pinned,oidalone is unambiguous for the deployment. Usepreferred_usernameinstead only when readable principal ids matter more than surviving a rename.emaildefaults to the UPN, read from whichever claim carries it:preferred_usernamein a v2.0 token,upnin a v1.0 one, which has nopreferred_username. SettingemailClaimpins it to that one claim. Entra omitsemailfrom access tokens unless the registration adds it as an optional claim, and publishes no picture claim at all, soavatarUrlClaimis 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/callbackand post-logout redirect URIhttps://<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 matchesrequiredScope. - 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/callbackand a client secret to the registration that exposes Bubble's API, so the stored token'saudis 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:
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:
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.jsonloginParameters 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:
interface BubbleAuthenticator {
authenticate: (request: BubbleAuthRequest) => Promise<BubbleAuthContext> | BubbleAuthContext;
}Use the helpers in @octostaff/bubble to keep the bearer parsing
consistent:
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-tokendoes. 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.