Skip to main content

Authentication

Email/password and platform sign-ins are completed in the ID app. A verified email address is an independent sign-in credential; Lumio does not auto-link profiles from an email claim supplied by a platform. Where 2FA is enabled, OAuth, password and PKCE authorization return a pending challenge with mfaMethods (passkey and/or totp) until an app code, passkey or recovery code is verified. A passkey sign-in completes 2FA in one browser ceremony. The confirmation link only verifies the address and does not sign anyone in.

Sensitive profile actions use a ten-minute session-bound step-up. With 2FA enabled, it accepts a passkey, authenticator app code or recovery code; otherwise an existing provider connection or email password can confirm identity. STEP_UP_REQUIRED means the client should ask the user to confirm again. See REST profile security routes.

Lumio resolves every incoming request into one of eleven authentication contexts. The auth middleware never rejects a request by itself - it resolves the strongest context it can and falls back to Anonymous; the individual route, resolver, or WebSocket gate then decides whether that context is sufficient.

Auth Types​

ContextPrefixLengthCarries RBACPurpose
System Keylm_sys_71System-scope grants (may include resource:* / *:*)Internal service-to-service and bot auth
User API Keylm_usr_71Scoped subset of the creator's account permissionsProgrammatic external API access
Service Keylm_svc_71Scoped subset of the creator's account permissions (no user identity)Account-owned CI/CD credential that survives member departure
Developer API Keylm_dev_71The extension-dev:* family only, bound to a developer profile or team (no account)lumio deploy / CI publishing for extension developers
JWTlm_ + eyJ…variableAdmin, account and user permission setsLogged-in browser sessions
Popout Tokenlm_pop_71Narrow subset of the creator's permissionsOBS browser sources, dashboard popout windows
Overlay Tokenlm_overlay_75nonePer-overlay browser-source access
Shared Overlay Tokenlm_share_73noneTime-limited shared overlay links
Extension Tokenlm_ext_71noneExtension iframe access to its own install data
Widget Tokenlm_widget_74nonePer-widget-instance browser-source access
Anonymous--noneNo credential presented

Every non-JWT token is a prefix plus 32 random bytes hex-encoded (64 characters). Only the SHA-256 hash is stored; the raw value is shown once at creation.

Channels being dissolved​

While a channel is scheduled for dissolution (dissolution_scheduled_for set), the auth middleware checks the resolved credential right after resolution. Credentials bound to that channel - User API Keys, Service Keys, Popout, Overlay, Shared Overlay, Widget and Extension Tokens - are refused with 403 CHANNEL_DISSOLVING (details.dissolution_scheduled_for names the date). They are not revoked and work again once the owner cancels the dissolution. A member's JWT session keeps its identity but holds no account permission for the channel: permission-gated routes answer 403 CHANNEL_DISSOLVING (GraphQL extensions.code), while user-level routes - profile, me with the account list, switching and leaving accounts, the owner's cancel - keep working. The WebSocket upgrade is refused for every credential of the channel, sessions included. See REST API → Channels being dissolved.

Transports​

Two transports are accepted, and what each accepts differs:

ContextAuthorization: Bearer …?token=… query param
System Key (lm_sys_)✓✗
User API Key (lm_usr_)✓✗
Service Key (lm_svc_)✓✗
Developer API Key (lm_dev_)✓✗
JWT (lm_eyJ…)✓✓
Popout Token (lm_pop_)✗✓
Overlay Token (lm_overlay_)✓✓
Shared Overlay Token (lm_share_)✓✓
Extension Token (lm_ext_)✓✓
Widget Token (lm_widget_)✓✓

Long-lived service credentials (System keys, User API keys) are header-only on purpose: query strings end up in access logs, browser history, and Referer headers. The query-param path exists because the browser WebSocket API cannot set custom headers.

Resolution is ordered: the Authorization header is inspected first and, when it carries a recognised prefix, that result is final - a ?token= value is not consulted as a fallback. An unrecognised or invalid credential resolves to Anonymous rather than an error.

Rate Limits​

Limits are per auth context, fixed per tier - they are not derived from the account's plan.

ContextRequests / minute
System Keyunlimited (rate limiting skipped)
User API Key1200
JWT600
Popout / Overlay / Shared Overlay / Extension / Widget Token600
Anonymous120

Popout tokens are keyed on the SHA-256 hash of the token, so several popout tokens on the same account get independent buckets - one overactive OBS browser source cannot starve the account's other popouts.

Anonymous requests are keyed on a client IP the caller cannot forge: Cloudflare's CF-Connecting-IP, else the peer address. X-Forwarded-For, X-Real-IP and X-Lumio-Client-IP never choose the bucket of a direct request. An IPv6 client is counted per /64 network, an IPv4 client per address.

A valid X-Internal-Token (our own Next.js servers calling the API) grants no exemption. An anonymous call our web, admin or id app makes on a visitor's behalf is counted in the proxied:{ip} bucket of the visitor IP it forwards in X-Lumio-Client-IP, at 600 requests / minute. That is more than a direct anonymous caller gets, because one server-rendered page view makes several API calls, and it is a separate bucket, so it never adds to that IP's direct budget. An internal call that forwards no visitor (background or build-time traffic) shares one internal:anon bucket of 1200 requests / minute. The forwarded IP is trusted only together with the internal token and only when it parses as an IP address. Authenticated internal calls are counted per credential as usual.

Inbound webhook receivers under /v1/webhooks/* are not rate limited: they live in a dedicated scope outside the limiter and are gated by per-platform signature verification instead. Platform deliveries arrive from a shared pool of the platform's own IPs, so the per-IP anonymous bucket would throttle every tenant's callbacks into a single 120/min cap (ZAF-1519). See Webhooks → Rate limiting.

JWT (JSON Web Tokens)​

Issued by the ID app (NextAuth) and validated by the API. A JWT resolves to the User context and carries three separate permission sets: admin_permissions (from admin roles), account_permissions (from the active account's role), and user_permissions (cross-account, non-admin - e.g. Ideas Hub).

Authorization: Bearer lm_eyJ...

The token may carry a session_id referencing the sessions row for the request. A JWT is tagged with its use: access (authorizes any REST/GraphQL/WebSocket request), ws (authorizes only the /v1/ws upgrade), or refresh (accepted only at /auth/refresh). The issueWsToken mutation (REST twin POST /v1/auth/ws-token) mints a dedicated ws JWT that deliberately carries no session_id and a short TTL (auth.ws_token_expiration, ~15 min), so a leaked WebSocket URL cannot be replayed against a REST/GraphQL mutation or a session-management endpoint. Raw JWTs passed in the ?token= query string are honored only on the WebSocket upgrade path.

User API Keys​

Long-lived keys (lm_usr_) for programmatic access, created from Settings → Channel → Access → API Keys (or through the API key endpoints). The full key is shown once, only its hash is stored, and each key carries an explicit permission scope that can never exceed what its creator held at creation time. A personal key is member-bound: it stops authenticating once its owner is no longer a member of the key's account (removing the member purges their personal keys, and the auth layer refuses any lm_usr_ key whose owner has left the account). For a credential that must outlive team changes, use an account-owned Service Key (lm_svc_) instead.

Authorization: Bearer lm_usr_...

GET /v1/tokens/me (and GraphQL myPermissions) returns the caller's effective permissions - a scoped key reports exactly its own scope, never a wildcard.

Session-only Surfaces​

A small class of operations defines who you are, mints a credential, or moves money. They accept only an interactive sign-in session (the JWT the ID App issues at login, which is bound to a revocable session row through its session_id). A user JWT that carries no session_id is not a session and is refused like an API key. A user's own API key (lm_usr_) is refused here regardless of its scopes, as is every token type and every system / service / developer key:

OperationRESTGraphQL
Link or reconnect a login providerPOST /v1/auth/link– (REST only; called by the ID App)
Switch or clear the active accountPATCH /v1/users/me with active_account_id / clear_active_accountupdateMe(input: { activeAccountId }) / updateMe(input: { clearActiveAccount })
Rearrange or remove login identitiesPOST /v1/accounts/login-assignments, DELETE /v1/accounts/login-assignments/{provider}, DELETE /v1/login-connections/{id}, PUT /v1/users/me/name-source-connection, PATCH /v1/users/me/display-name, POST/DELETE /v1/users/me/avatarassignLoginConnection, removeLoginAssignment, disconnectLoginConnection, setNameSourceConnection, updateDisplayName, removeAvatar (the upload has no GraphQL twin)
Change the channel's name or picture (also needs account:edit and the active channel)PUT /v1/accounts/{id}/identity-source, PATCH /v1/accounts/{id}/name, POST/DELETE /v1/accounts/{id}/avatarsetAccountIdentitySource, updateAccountName, removeAccountAvatar, deprecated updateAccount (the upload has no GraphQL twin)
Mint a WebSocket JWTPOST /v1/auth/ws-tokenissueWsToken
Mint / rotate a personal API keyPOST /v1/api-keys, POST /v1/api-keys/{id}/rotatecreateUserApiKey, rotateUserApiKey
Mint / rotate an account service keyPOST /v1/service-keys, POST /v1/service-keys/{id}/rotatecreateAccountServiceKey, rotateAccountServiceKey
Mint a developer keyPOST /v1/developer/api-keyscreateDeveloperApiKey
Start a channel / bot OAuth connectGET /v1/connections/channel/{platform}/authorize, GET /v1/bot-connections/{platform}/authorizeauthorizeChannel, authorizeBot
Complete a channel / bot OAuth connectPOST /v1/connections/oauth/{platform}/exchange, POST /v1/connections/channel/{platform}/exchange, POST /v1/bot-connections/{platform}/exchange– (REST only; called by the web callback)
Set the payout destination (Stripe Connect onboarding)POST /v1/developer/stripe-connect/onboard– (REST only)
Change developer payout settingsPOST /v1/developer/payoutsupdateDeveloperPayoutSettings
Request a developer payoutPOST /v1/developer/payouts/requestrequestPayout
Start a plan checkout, open the billing portal, cancel the subscriptionPOST /v1/billing/checkout, POST /v1/billing/portal, POST /v1/billing/cancelcreateCheckout, createPortal, cancelSubscription

A WebSocket JWT carries the user's full permission set, so an API key cannot mint one - present the lm_usr_ key itself on /v1/ws (Authorization header). The popout ws-token path (POST /v1/auth/popout/ws-token / issuePopoutWsToken) is separate and unaffected. Popout-token creation (POST /v1/tokens / createPopoutToken) is not session-only: an API key may mint a popout token, capped to the key's own scopes.

A leaked or over-scoped API key therefore cannot redirect payouts or change billing. The money reads stay open to a user's own API key with the right scope: GET /v1/developer/stripe-connect/status, GET /v1/developer/payout-settings / developerPayoutSettings, payout history, revenue and the billing status / invoice reads. The billing mutations check the session before they check whether billing is enabled, so an API key always gets the session refusal.

Only the active_account_id and clear_active_account fields of PATCH /v1/users/me / updateMe are session-only (email is refused for every caller with 400 EMAIL_MANAGED_BY_LOGIN; the profile email is set by the email sign-in) - an API key can still change the other profile fields (streamer mode, display preferences), and the response never carries a token for a non-session caller.

A session JWT is only minted for a caller that already holds a session (ZAF-1922). The account switch / clear above, account creation and account dissolution each hand back a replacement JWT bound to the caller's existing session (lo_auth::reissue_session_token). Creating an account (POST /v1/accounts / createAccount) and dissolving one (DELETE /v1/accounts/\{id\}) are not session-only - they run under their usual guards - but a caller without an interactive session gets token: null; GraphQL dissolveAccount returns no token at all.

Refusals are identical on both protocols: anonymous → 401 / UNAUTHENTICATED (Authentication required); any other credential → 403 / FORBIDDEN with This action requires an interactive sign-in session.

The channel / account-bot OAuth exchange is additionally bound to the session that started it: the authorize step records the signed-in user on the server-side state handle, and the exchange completes only for that same user's session, re-checking account access and the connect permission (connections:create / bot-connections:create). Any other caller is refused before a connection row is written, and the single-use state is spent. The admin global-bot connect is exempt: it returns through the web host, where the browser session is never an admin session, and only the admin-only authorize step can start it.

Developer API Keys​

Long-lived publishing credentials (lm_dev_) for extension developers - the key lumio deploy and CI use instead of a personal lm_usr_ key. A developer key is deliberately narrow and differs from every other credential in three ways:

  • Bound to a developer identity, not an account or a member. It is owned by a developer profile or a developer team (exactly one), and carries no account and no member identity - so it can never satisfy an account-scoped check and, like a Service Key, keeps authenticating after the member who minted it leaves the team.
  • Plan-decoupled. Eligibility is approved-developer status (an existing developer profile / team membership), not the Pro plan. The feature:extension_development account plan gate does not apply to a developer key - free-tier developers keep publishing when API keys are Pro-only.
  • Scoped to extension-dev:* only. A developer key can carry only the extension-dev:* permission family (read, create, edit, delete, publish, analytics, payouts) - never an ordinary account scope such as chat:ban and never a user-scoped scope such as ideas:*. Scopes can never exceed what the creator held at mint time.

It reaches the extension-developer publishing routes and nothing else: listing / reading / updating / deleting its own extensions, versions (create / upload / list), analytics, limits, purchases, testers, screenshots and the extension's secrets (GET/PUT/DELETE …/secrets; secrets belong to the extension, not to an account). It is refused (fail-closed) from:

  • the first-party developer surface - developer profile, payout settings, revenue, payout requests and limit requests (these key off the acting logged-in user), plus create / submit / invite tester / request deletion (they require a first-party user);
  • developer-key management itself - a lm_dev_ key can never mint or revoke another developer key, and minting one requires an interactive sign-in session (see Session-only Surfaces).

Manage developer keys from Settings → You → Developer → Developer keys (/dashboard/settings/developer/keys; REST /v1/developer/api-keys, GraphQL developerApiKeys / createDeveloperApiKey / updateDeveloperApiKey / revokeDeveloperApiKey). The full key is returned once at creation and only its hash is stored.

Authorization: Bearer lm_dev_...

System Keys​

Service identities that are not bound to an account: a system key can act on any account, but only within the scopes it carries. Two provisioning surfaces resolve to the same System context: static keys from TOML configuration (an empty list in the deployed API - every live system key is admin-provisioned), and admin-provisioned keys stored in api_keys with is_system = true. System keys are the only contexts that legitimately hold resource:* or *:* grants.

Internal System surfaces check a named grant, not the key type alone: the bot fleet's feeds, the bot chat ingest, extension functions on any install, draft extension files and bundles, and the WebSocket presence:* / sounds:* channels require bots:internal (a system-key-only permission), the audit ingest requires audit:ingest. The GraphQL SystemGuard takes the grant as an argument (SystemGuard::new(grant)); there is no type-only form. A system key without the grant is refused with 403 / Missing permission: ….

Authorization: Bearer lm_sys_...

Popout Tokens & Popout Sessions​

Popout tokens (lm_pop_*) back OBS browser sources and dashboard popout windows. Unlike overlay tokens they carry a narrow RBAC permission subset (never more than their creator holds) rather than being bound to a single overlay.

The raw token appears only in the very first page request. On that first load a popout page exchanges it once, server-side, for a short-lived popout session; the page then redirects to the token-less URL and every subsequent proxied API call, WebSocket subscription, and SSR fetch authenticates through the session cookie. The raw lm_pop_* token is never forwarded to a proxied API call - proxy auth reads the session cookie only, so a logged-in operator can never be downgraded to token-scoped rights by a ?token= left in a URL.

POST /v1/auth/popout/exchange { "token": "lm_pop_..." }
# or GraphQL: mutation { exchangePopoutToken(token: "lm_pop_...") { token expiresAt accountId } }
  • The response returns an lm_-prefixed session JWT and also sets it as an httpOnly cookie lumio-popout-token (Path=/, SameSite=Lax, Secure outside development, Max-Age = 15 min). Store it as an httpOnly cookie - never expose it to client-side JavaScript.
  • The session resolves to the same popout auth context as the raw token (identical account, permission subset, and OBS credential gate) - it is not a full user session, so it cannot escalate to the delegated user's roles.
  • TTL is 15 minutes. When it expires, the popout page transparently re-exchanges the still-valid raw lm_pop_* token for a fresh session. Every request additionally revalidates the origin token (revoked, expired, deleted), so deleting or revoking the popout token kills active sessions immediately; an open WebSocket closes on its next 30-second credential re-validation, or with TOKEN_EXPIRED when its session JWT expires.
  • Possession of a valid lm_pop_* token IS the authorization - the exchange endpoint has no permission guard, since presenting the raw popout token has always been proof of access. A revoked, expired, unknown, or non-popout token - or one assigned to a member who has left the account - is rejected with Invalid or expired popout token.
  • WebSocket credential. A popout mints its /v1/ws token via issuePopoutWsToken (REST twin POST /v1/auth/popout/ws-token) - not issueWsToken, which is session-only (ZAF-469 / ZAF-1742) and rejects a popout. The popout mint carries the popout's own permission subset (never the account owner's), a WebSocket-only use tag, and no session_id, so the API honors it only on the /v1/ws upgrade path.
Authorization: Bearer <popout-session-jwt> # forwarded from the httpOnly cookie
  • Profile writes: exactly two, both one-way. A popout session is read-only for the represented user's profile (updateMe / PATCH /v1/users/me are first-party gated and reject it) with two deliberate exceptions for the seasonal look: dismiss the season banner (popoutDismissSeasonBanner / POST /v1/auth/popout/season-banner/dismiss) and switch the seasonal look off (popoutSwitchOffSeasonLook / POST /v1/auth/popout/season-look/off). Both only append a <seasonId>:<year> instance to season_banner_dismissed; the second also sets season_look_enabled = false. No popout path can switch the look back on or change strength, motion, creatures, language or any other field - that stays in dashboard Settings. These two routes are popout-only: anonymous gets 401, every other credential (user session, API key, overlay, shared-overlay, widget, extension) 403 This endpoint requires a popout session.

Member-bound. A popout token bound to a member stops working once that member is no longer part of the account: removing the member (or the member leaving) deletes the popout tokens assigned to them for that account, and the raw-token resolver, the exchange, the popout-session resolver and the WebSocket re-validation additionally refuse any token whose assigned member is neither a current member nor the owner of the token's account.

Always bound to an explicit user (ZAF-1922). Every popout token resolves to the user it is bound to - creating without a user binds the creator, unassigning binds the editor - and there is no fallback to the account owner. A token row with no bound user is rejected by the raw-token resolver, the exchange and the WebSocket re-validation like an invalid token.

A popout token's user_id is the user it is bound to, not the person presenting it, so first-party routes that treat the user id as the acting principal (profile edits, session revocation, notifications, developer application, account dissolution) use first_party_user_id() instead - that accessor returns a value only for User and ApiKey contexts and rejects the whole token class.

Overlay Tokens​

Per-overlay access tokens for OBS browser sources, bound 1:1 to a specific overlay row. They carry no RBAC permissions: has_permission() returns false for every input, and they cannot reach REST or GraphQL resources. Their sole capability is subscribing to the overlay:\{key\} WebSocket channel whose resolved overlay matches the token's overlay_id.

?token=lm_overlay_...
Authorization: Bearer lm_overlay_...

Sessions authenticated with an overlay token are re-validated against the database every 30 seconds, like every token-authenticated WebSocket session (see Credential re-validation); a rotated, revoked or deleted token is disconnected with TOKEN_REVOKED.

Shared Overlay Tokens​

Time-limited links for sharing an overlay with collaborators or a backup streaming setup, without granting account access. Same capability envelope as an overlay token, plus an expires_at. Expiry is checked on every 5-second heartbeat tick (TOKEN_EXPIRED) and revocation on the 30-second credential re-validation (TOKEN_REVOKED).

Extension Tokens​

Short-lived tokens (15-minute TTL, auto-refreshed via postMessage) issued to an extension iframe. Scoped to a single extension installation: extension_id, install_id, and account_id. They carry no RBAC permissions - capability comes from the permissions the extension declared in lumio.config.json and was granted at install time, plus access to the installation's isolated storage.

Widget Tokens​

Per-widget-instance access tokens for browser sources, bound 1:1 to a widget_id. No RBAC permissions; the sole capability is subscribing to the widget:\{widget_instance_id\} WebSocket channel matching the bound widget.

Anonymous​

The fallback context when no credential is presented or the presented credential does not resolve. Anonymous callers reach only explicitly public surfaces (health probes, public plan/provider reads, the fonts proxy, platform webhooks, and WebSocket channels whose gate is Public or PublicWhenFeature).

RBAC​

Authenticated requests are subject to role-based access control using resource:action permission strings, checked by require_permission() (REST), PermissionGuard (GraphQL), and the channel gate (WebSocket). See Permissions for the full registry and Feature Flags for feature-level access control.