Authentication
Lumio resolves every incoming request into one of nine 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
| Context | Prefix | Length | Carries RBAC | Purpose |
|---|---|---|---|---|
| System Key | lm_sys_ | 71 | System-scope grants (may include resource:* / *:*) | Internal service-to-service and bot auth |
| User API Key | lm_usr_ | 71 | Scoped subset of the creator's account permissions | Programmatic external API access |
| JWT | lm_ + eyJ… | variable | Admin, account and user permission sets | Logged-in browser sessions |
| Popout Token | lm_pop_ | 71 | Narrow subset of the creator's permissions | OBS browser sources, dashboard popout windows |
| Overlay Token | lm_overlay_ | 75 | none | Per-overlay browser-source access |
| Shared Overlay Token | lm_share_ | 73 | none | Time-limited shared overlay links |
| Extension Token | lm_ext_ | 71 | none | Extension iframe access to its own install data |
| Widget Token | lm_widget_ | 74 | none | Per-widget-instance browser-source access |
| Anonymous | — | — | none | No 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.
Transports
Two transports are accepted, and what each accepts differs:
| Context | Authorization: Bearer … | ?token=… query param |
|---|---|---|
System Key (lm_sys_) | ✓ | ✗ |
User API Key (lm_usr_) | ✓ | ✗ |
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.
| Context | Requests / minute |
|---|---|
| System Key | unlimited (rate limiting skipped) |
| User API Key | 1200 |
| JWT | 600 |
| Popout / Overlay / Shared Overlay / Extension / Widget Token | 600 |
| Anonymous | 120 |
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.
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 Dashboard → 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.
System Keys
Service identities that bypass per-account scoping. Two provisioning surfaces resolve to the same System context: static keys from TOML configuration, and admin-provisioned keys stored in api_keys with is_system = true. System keys are the only contexts that legitimately hold resource:* or *:* grants.
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 cookielumio-popout-token(Path=/,SameSite=Lax,Secureoutside 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'srevoked_at, so deleting or revoking the popout token kills active sessions immediately. - 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 is rejected withInvalid or expired popout token. - WebSocket credential. A popout mints its
/v1/wstoken viaissuePopoutWsToken(REST twinPOST /v1/auth/popout/ws-token) — notissueWsToken, which is first-party gated (ZAF-469) 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 nosession_id, so the API honors it only on the/v1/wsupgrade path.
Authorization: Bearer <popout-session-jwt> # forwarded from the httpOnly cookie
A popout token's user_id is optional. When it is unset, AuthContext::user_id() falls back to the account owner, 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; a rotated or revoked 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 DB revocation every 30 seconds (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.