Skip to main content

Auth

Overview​

Lumio supports email/password and platform sign-in through the ID app. The API resolves eleven auth contexts (including developer keys and Anonymous), handles sessions and 2FA, and enforces RBAC through lo-auth.

In Security → Sign-in, a passkey row shows the user's label and the provider name or YubiKey model when known. A provider logo appears next to that name when available; providers without a usable logo and unknown providers show a generic passkey symbol. The trademark notice appears below the list whenever a provider logo is visible. See Account Security for setup and recovery.

Auth Types​

TypePrefixUse CaseRate Limit
System Keylm_sys_Internal service-to-service communicationUnlimited
User API Keylm_usr_Personal, per-member external API access1200 req/min
Service Keylm_svc_Account-owned key for CI/CD & integrations (survives member departure)1200 req/min
Developer API Keylm_dev_Approved developer or team extension development1200 req/min
JWTlm_ + eyJ...Session-based auth after OAuth login600 req/min
Popout Tokenlm_pop_Browser-source and dashboard popout access with a scoped RBAC permission set600 req/min
Overlay Tokenlm_overlay_One-overlay browser-source WebSocket access600 req/min
Shared Overlay Tokenlm_share_Time-limited shared overlay WebSocket access600 req/min
Extension Tokenlm_ext_Short-lived extension iframe/runtime access600 req/min
Widget Tokenlm_widget_One-widget browser-source WebSocket access600 req/min
Anonymous(none)Unauthenticated public access120 req/min

Token type is automatically identified from the prefix via identify_token().

Architecture​

Client Request
|
v
Auth Middleware (identify_token -> resolve AuthContext)
|
+-- lm_sys_* --> System Key lookup (config + DB, hash match)
+-- lm_usr_* --> User API Key lookup (DB, hash match)
+-- lm_svc_* --> Service Key lookup (account_service_keys, hash match)
+-- lm_dev_* --> Developer API Key lookup
+-- lm_share_* --> Shared overlay token lookup
+-- lm_overlay_* --> Overlay token lookup
+-- lm_ext_* --> Extension token lookup
+-- lm_widget_* --> Widget token lookup
+-- lm_eyJ* --> JWT validation (decode + verify)
+-- ?token=lm_pop_* --> Popout token lookup (DB, hash match)
+-- (none) --> Anonymous
|
v
AuthContext (enum: System | ApiKey | ServiceKey | DeveloperKey | User | PopoutToken | OverlayToken | SharedOverlayToken | ExtensionToken | WidgetToken | Anonymous)
|
v
Permission checks (PermissionGuard / require_permission)
|
v
Handler / Resolver

OAuth Login Flow​

  1. User initiates login in the ID App (NextAuth-based).
  2. ID App handles OAuth with the provider (Twitch, YouTube/Google, Discord, Kick).
  3. ID App calls the exchangeToken GraphQL mutation with:
    • Provider name and provider-specific user ID
    • OAuth access token
    • User profile (display_name, username, avatar_url, email)
  4. The API finds or creates the user via find_or_create_user_by_provider.
  5. If 2FA is enabled, the API returns a pending challenge. The ID app offers a passkey, authenticator app code or recovery code before creating the session or a PKCE code. A passkey sign-in completes both steps. A trusted device may skip this step for 30 days.
  6. Once the sign-in is complete, a session is created with a SHA-256 hash of the refresh token's jti and cached in Redis.
  7. A JWT and refresh token are returned. Email/password sign-in follows the same 2FA gate. Confirming an email address alone never signs the user in.

Changes to sign-in methods and enabling 2FA require a fresh identity confirmation on the interactive session. The confirmation expires after ten minutes. With 2FA enabled, a passkey, authenticator app code or recovery code counts; without it, the user can confirm with an existing platform connection or email password. A reconnect to the same provider account is exempt. Platform OAuth step-up does not request provider max_age, so a provider may reuse an existing login session.

Token Refresh Flow​

  1. Client sends expired JWT's refresh token to the refreshToken mutation.
  2. Refresh token is validated and the session is looked up by token hash.
  3. A new JWT is issued with the same session ID.
  4. A new refresh token is issued with the remaining session lifetime (refresh token rotation). The session's token_hash is updated atomically, invalidating the old refresh token.

Proxy-Level Auto-Refresh​

All three Next.js apps (web, admin, ID) run a proxy.ts that intercepts every request and proactively refreshes the JWT when it is within 60 seconds of expiry. The proxy calls the GraphQL refreshToken mutation directly and sets updated cookies on the response. The user experiences no interruption.

If the API is unreachable during a refresh attempt, the proxy passes the request through without logging out - the page renders and shows a service-unavailable error instead.

Public web pages that show the signed-in viewer refresh too, but never redirect to the login: the marketing pages (/, /features, /pricing, /music, /invite, /ideas and their sub-pages), /legal/*, /withdraw, /cancel, the session-reading public API /api/ideas/*, and /popout/* when no popout session cookie is present (the popout then falls back to the dashboard cookie). With an expired JWT and a valid refresh cookie the page renders signed in. Without a session, or when the refresh is refused, the page renders signed out and the cookies stay as they are. File routes such as /ideas/feed.xml, robots.txt and sitemap.xml, the share images under /og/, the share pages under /share/, and /overlay / /widget (they authenticate with their own token) are never refreshed: a shared cache could replay a Set-Cookie on a cacheable response to other visitors.

Cross-Origin Request Guard​

The session cookies are SameSite=Lax. SameSite counts a whole site, so a request from any sibling origin (for example docs.lumio.vision to lumio.vision, or one *.zaflun.dev host to another) still carries them. The proxy.ts of all three Next.js apps therefore refuses a POST, PUT, PATCH or DELETE to /api/* with 403 FORBIDDEN unless it comes from the app's own pages:

  • With a Sec-Fetch-Site header (every current browser), only same-origin passes.
  • Without it, the Origin header must equal the app's configured public origin: LUMIO_APP_URL for web and admin (web also accepts the overlay origin from LUMIO_OVERLAY_URL), AUTH_URL for the ID app. A request with neither header is refused.

The guard runs before any auth check, so it also covers the public /api/* routes. GET, HEAD and OPTIONS are not checked. The only exemption is web's /api/csp-report, which browsers post to from the overlay and widget pages. The check lives in shared/api/src/same-origin.ts (@lumio/api/same-origin); a new route that must accept cross-origin writes needs an explicit, commented exemption in that app's proxy.ts.

Client-Side Refresh Timer​

The web and admin apps run a useTokenRefresh() hook that proactively refreshes the JWT for long-lived pages (popouts, chat, dashboards left open overnight). The scheduler lives in src/lib/token-refresh.ts of each app; it calls POST /api/auth/refresh and reacts to the answer:

  • Success - the next refresh is scheduled 5 minutes before the new token expires (at least 60 seconds later).
  • 401 - the session is dead (revoked, expired, or another tab already rotated the refresh token). The hook stops for good and reloads the page once. The reload goes through the proxy: for a dead session it clears the cookies and redirects to the ID login; if another tab won the rotation, the browser already holds the new cookies and the page simply loads. A sessionStorage marker allows at most one such reload per 5 minutes, so a reload loop is impossible.
  • Popouts (/popout/*, also in OBS) stop on a 401 and never reload: the proxy skips /popout, so a reload would not clear anything.
  • Network error, 5xx or 429 - retried with exponential backoff: 60 s, 2, 4, 8 minutes, capped at 15 minutes. A success resets the backoff.
  • Single-flight across tabs - each refresh runs inside the Web Locks API lock lumio-token-refresh (navigator.locks; a plain call where the API is missing), so two tabs never rotate the same refresh token at once.

The timer only fires while the tab is visible; becoming visible again triggers an immediate refresh unless the hook has stopped.

Neither the refresh route nor the proxies' /api/* branch deletes cookies on a failed refresh. Refresh-token rotation has no grace window, so with two concurrent refreshes the loser's delete could overwrite the winner's fresh cookies and log the user out. Only the page-navigation branch of the proxy clears them.

Logout Flow​

  1. Client sends the refresh token to the logout mutation.
  2. Session is deleted from PostgreSQL and Redis.
  3. Returns success even if the token was already expired.

API​

GraphQL Queries​

QueryArgsReturnsGuard
me--MeResult!AuthGuard
myPermissions--[String!]!AuthGuard

MeResult includes:

  • User profile (id, displayName, email, avatarUrl, createdAt)
  • activeAccountId (nullable)
  • accounts: [AccountMembership!]! (role, plan, owner status, the channel's name and picture as identity)
  • permissions: [String!]! -- resolved account-scoped permissions
  • adminPermissions: [String!]! -- resolved admin-scope permissions
  • userPermissions: [String!]! -- resolved user-scoped (cross-account, non-admin) permissions, e.g. Ideas Hub ideas:moderate_*. This is the exact set AuthContext::require_user_permission enforces, so the frontend can gate user-scoped controls on it without drifting from the backend. Account-independent (unchanged by an active-account switch). REST parity: user_permissions on GET/PATCH /users/me.
  • loginConnections: [LoginConnection!]!
  • enabledFeatures: [String!]!
  • featureStatuses: [FeatureStatusGql!]! -- merged account-scope flags plus the user-scope system:account_creation flag
  • token: String -- present only when updateMe switched the active account
  • ownedAccountCount: Int! / maxAccounts: Int! -- multi-account ownership counters
  • streamerMode: Boolean! -- hides sensitive data in the UI
  • isDeveloper: Boolean! -- has a developer_profiles entry or an admin override
  • extensionDevMode: Boolean! -- load draft extension versions
  • locale: String! -- UI language preference: auto · en · de
  • timezone: String! -- timezone preference: auto or an IANA name
  • dateFormat: String! -- date-format preset: auto · dmy_dot · dmy_slash · mdy_slash · ymd_dash
  • timeFormat: String! -- time-format preset: auto · h12 · h24
  • seasonLookEnabled: Boolean! / seasonStrength: String! (full · subtle) / seasonMotion: String! (auto · off) / seasonCreatures: JSON! / seasonBannerDismissed: [String!]! -- seasonal-look preferences (see GraphQL API)
  • motionPreference: String! -- "Animations in Lumio": system · reduced · off (see GraphQL API)

The four display preferences, the five seasonal-look preferences and the motion preference are user-global (stored on users, applied across the webapp) and settable via updateMe / PATCH /users/me; an invalid value is rejected fail-closed on both protocols (never coerced to auto), and changing them emits no audit event.

GraphQL Mutations​

MutationArgsReturnsGuard
exchangeTokeninput: ExchangeTokenInput!TokenResult!None (public)
refreshTokenrefreshToken: String!TokenResult!None (public)
logoutrefreshToken: String!LogoutResult!None (public)
logoutSession--LogoutResult!AuthGuard
disconnectLoginConnectionloginConnectionId: UUID!LogoutResult!AuthGuard + session + step-up
updateMeinput: UpdateMeInput!MeResult!AuthGuard

ExchangeTokenInput​

input ExchangeTokenInput {
provider: String! # "twitch", "discord", "google", "kick"
providerId: String! # Provider-specific user ID
accessToken: String! # OAuth access token from provider
profile: ProviderProfileInput!
}

input ProviderProfileInput {
displayName: String!
username: String
avatarUrl: String
email: String
}

TokenResult​

type TokenResult {
token: String! # Lumio JWT (short-lived, lm_ prefix)
refreshToken: String! # Refresh token (long-lived)
expiresAt: String! # JWT expiration (ISO 8601)
isNewUser: Boolean! # First login ever
hasAccount: Boolean! # User has at least one account
}

REST Endpoints​

Auth REST endpoints are public (no permission guard) unless noted. They live under /v1/auth.

MethodPathDescription
POST/v1/auth/tokenIssue a JWT for a dashboard login (ID App -> API handshake).
POST/v1/auth/refreshExchange a refresh token for a new JWT.
POST/v1/auth/logoutInvalidate the current refresh token/session.
POST/v1/auth/authorizeOAuth 2.0 authorize handshake for downstream clients.
POST/v1/auth/token/exchangeExchange a provider OAuth token for a Lumio JWT.
POST/v1/auth/linkLink an additional provider identity to the authenticated user (session JWT required; a new link also needs a step-up, a reconnect of the same connection does not).
POST/v1/auth/ws-tokenMint a short-lived WebSocket handshake token for the current principal.
POST/v1/auth/popout/exchangeExchange a raw lm_pop_* token for the short-lived popout session cookie (see Popout Tokens).

Bodies are application/json with snake_case fields. Response shapes match the GraphQL TokenResult / LogoutResult types.

Step-up before sign-in changes​

Turning on two-factor authentication (POST /v1/users/me/mfa/setup and POST /v1/users/me/mfa/enable, GraphQL startMfaSetup / enableMfa), linking a new sign-in (POST /v1/auth/link without reconnect) removing one (DELETE /v1/login-connections/{id}, GraphQL disconnectLoginConnection) dissolving a channel (DELETE /v1/accounts/{id}, GraphQL dissolveAccount, ZAF-2052) and scheduling a channel dissolution (POST /v1/accounts/{id}/dissolution, GraphQL scheduleAccountDissolution) need a step-up on the calling session in the last 10 minutes. Without it both protocols answer 403 with code STEP_UP_REQUIRED and the message "Please confirm that it's you first", and nothing changes. The dashboard then shows its "Is it you?" dialog. Without 2FA the step-up is a platform round trip or the password; with 2FA a passkey, app code or recovery code counts.

enableMfa checks the mark again, so a setup started under a fresh mark cannot be finished after the mark ran out. A reconnect (reconnect: true with the connection_id of a connection of the same platform account) adds no new sign-in and needs no step-up. Removing a connection checks the "last sign-in method" rule first, so nobody is asked to confirm a removal that cannot happen; dissolving checks account:delete, ownership, the active account, the typed name and the absence of an active subscription (ACTIVE_SUBSCRIPTION) first in the same way. Cancelling a scheduled dissolution needs no step-up. API keys, service keys and popout tokens cannot hold a step-up, so they cannot dissolve a channel.

The ID app calls /v1/auth/link with the shared lumio-token session cookie, so a step-up done in the dashboard counts for the link that follows.

In the dashboard (Security › Sign-in) the dialog comes before each of these actions and is skipped while the session counts as confirmed:

  • Two-factor › Set up confirms before step 1 of the set-up dialog. Turn on asks again if the mark ran out during the set-up.
  • Add connection › Continue to … confirms before the hop to the ID app. Reconnect needs no confirmation.
  • Remove connection (the unlink icon on a connection) first asks "Remove connection?", then confirms, then removes.

If the user confirms through a platform or the password, the dashboard remembers the action and continues it after ?step_up=ok. If the server still answers STEP_UP_REQUIRED because the mark ran out in the meantime, the dialog opens again instead of an error. When the mark runs out while the user is at the platform during a link, the ID app returns link_result=step_up_required to the dashboard. The dashboard then asks again and repeats the link with the same channels.

The ID app's confirm card names the reason and the account. The dashboard adds two display-only parameters: purpose, one of deleteProfile, export, dissolveChannel, emailLogin, mfa, addSignIn or removeSignIn, and account_name, the selected platform account. The card shows the reason in its subtitle, for example "Lumio asks before you delete your profile", and the button reads "Confirm with Twitch · cruex". Unknown values fall back to a general sentence. Neither parameter grants anything; the API checks the account itself. Choose another sign-in returns with step_up=choose. The dashboard resumes the stored action, and because nothing was confirmed, its dialog opens again.

User / Session Endpoints​

These live under /v1/users/me and are covered in detail in Sessions and the Users resource.

MethodPathPermissionDescription
GET/v1/users/meAuthCurrent user profile + permissions
PATCH/v1/users/meAuthUpdate email, preferences, active account (not the display name - see below)
PUT/v1/users/me/name-source-connectionSessionChoose the display name + avatar source: a login connection or own details (kind)
PATCH/v1/users/me/display-nameSessionSet the own display name (own details only)
POST/v1/users/me/avatarSessionUpload the own profile picture (multipart file; REST-only)
DELETE/v1/users/me/avatarSessionRemove the own profile picture
GET/v1/users/me/login-connectionsAuthList provider identities
GET/v1/users/me/sessionsAuthList active sessions
DELETE/v1/users/me/sessions/{id}AuthRevoke one session (ownership enforced in handler)
DELETE/v1/users/me/sessionsAuthRevoke all other sessions for the current user

AuthContext​

The AuthContext enum is the core of the auth system, resolved by middleware and available in every handler/resolver:

enum AuthContext {
System { name, permissions },
ApiKey { user_id, account_id, label, permissions, rate_tier },
User { user_id, account_id, session_id, admin_permissions, account_permissions, user_permissions },
PopoutToken { account_id, user_id, label, permissions, token_hash },
OverlayToken { token_id, overlay_id, account_id },
SharedOverlayToken { token_id, overlay_id, account_id, expires_at },
ExtensionToken { token_id, extension_id, install_id, account_id },
WidgetToken { token_id, widget_id, account_id },
Anonymous,
}

Permission Resolution​

  • System -- Checked against the system key's own permission list (not bound to an account, so it can act on any account within those permissions). Internal and cross-account System surfaces additionally require a named grant via require_system_permission - the bot fleet's feeds (ZAF-1922), extension functions on any install, draft extension files/bundles and the WebSocket presence:* / sounds:* channels (ZAF-1976) need bots:internal. No System surface is gated on the key type alone.
  • API Key -- Checked against the key's assigned permissions.
  • User (JWT) -- Admin-scope and account-scope permission lists are checked in order for the active account.
  • Popout Token -- Checked against the token's custom permission list.
  • Overlay / Shared Overlay / Extension / Widget Token -- No RBAC permissions; they only grant their own scoped runtime/WebSocket access.
  • Anonymous -- Always denied.

Account roles are registry-validated against explicit resource:action strings; wildcard grants are not assignable through roles. Runtime wildcard matching only applies to permission lists that legitimately contain wildcards, such as configured system keys.

JWT Structure​

Claims​

FieldTypeDescription
subUUIDUser ID
account_idUUID?Active account (can be switched)
session_idUUID?References sessions table (JWT auth only)
iati64Issued at (Unix timestamp)
expi64Expiration (Unix timestamp)
jtiUUIDUnique token identifier (UUID v7)

All JWTs are prefixed with lm_ for type identification.

API Keys​

System Keys (lm_sys_)​

  • Generated via generate_system_api_key()
  • Provisioned through the Admin panel (System → System API Keys), stored in the api_keys table with is_system = true and no owning user/account. The auth middleware resolves them by hash against that table, falling back to any static keys loaded from TOML config.
  • Used by internal services and bots. A key reaches only what its scopes allow: the bot fleet's internal surface requires the system-only bots:internal grant (ZAF-1922), so each bot/worker key carries it and the apps/id audit-ingest key (audit:ingest only) reaches nothing else. See System Keys → Provisioning bot and worker keys.
  • 32 random bytes, hex-encoded
  • Stored as SHA-256 hash for verification

User API Keys (lm_usr_)​

  • Generated via generate_user_api_key()
  • Created by users from Settings → Channel → Access → API Keys
  • Available when feature:apikeys is enabled
  • Gated by apikeys:read to view keys, apikeys:create to create keys, apikeys:edit to rename, and apikeys:delete to revoke keys
  • Personal to the creator within the active account: each key is bound to (user_id, account_id), and users only manage their own keys
  • Member-bound lifetime: a personal key stops authenticating the moment its owner is no longer a member of the account. Removing the member (or the member leaving) purges their personal keys for that account, and the auth layer additionally refuses any lm_usr_ key whose owner is not a current member (or the owner) of the key's account. This is the deliberate contrast with account-owned lm_svc_ service keys below, which survive member departure.
  • Permission checkboxes can only select a subset of the creator's current permissions; the backend rejects any requested permission the creator does not hold
  • Optional expiry can be set at creation time
  • Display prefix shows the first 2 chars of the random part (e.g., lm_usr_ab)
  • Full key shown once on creation, only hash stored in DB
  • Revoke hard-deletes the key

Service Keys (lm_svc_)​

Account-owned service API keys for CI/CD pipelines and long-lived integrations that must keep working across team changes. Unlike personal lm_usr_ keys, a service key is owned by the account, not a member, and survives the departure of the member who created it.

  • Generated via generate_service_api_key()
  • Created from Settings → Channel → Access → API Keys → Service Keys
  • Available when feature:apikeys is enabled (same flag and apikeys:* permission family as personal keys)
  • Gated by apikeys:read to view, apikeys:create to create, apikeys:edit to rename, and apikeys:delete to revoke
  • Creating or rotating a service key requires an interactive sign-in session. The caller must hold every permission assigned to the key; rotation rechecks the existing scopes before replacing its secret. An API key cannot create or rotate another service key.
  • Stored in the dedicated account_service_keys table (never api_keys), scoped to account_id
  • Carries no member identity: the middleware resolves it to an account-scoped principal (AuthContext::ServiceKey) with the key's own permission list; user_id is None, so it keeps authenticating after any member - including its creator - is removed from the account
  • created_by records the minting member only as a nullable audit pointer (ON DELETE SET NULL); removing that member sets it to null but never deletes the key
  • Permission checkboxes can only select a subset of the creator's current permissions; the backend rejects any requested permission the creator does not hold (same ZAF-371 subset guard as personal keys)
  • Optional expiry can be set at creation time
  • Display prefix shows the first 2 chars of the random part (e.g., lm_svc_ab)
  • Full key shown once on creation, only hash stored in DB
  • Rename changes the label only; permissions, key material and expiry are immutable once minted
  • Revoke hard-deletes the key
  • Managed on both protocols: GraphQL accountServiceKeys / createAccountServiceKey / updateAccountServiceKey / deleteAccountServiceKey and REST /v1/service-keys
  • Lifecycle emits account-scoped audit events account:service_key_created / account:service_key_updated / account:service_key_revoked

Popout Tokens (lm_pop_)​

  • Generated via generate_popout_token()
  • Long-lived by default, with optional expires_at and revoked_at lifecycle controls
  • Used for browser sources and dashboard popout windows
  • Limited, configurable permissions
  • 32 random bytes, hex-encoded, SHA-256 hashed for storage
  • The raw token is exchanged once, at page load, for a short-lived (15-minute) popout session cookie (lumio-popout-token); it never travels in a proxied API/WebSocket call. Proxy auth reads the session cookie only, so a ?token= left in a URL can never downgrade an already-logged-in operator to token-scoped rights. See API authentication.
  • Always bound to an explicit user (ZAF-1922): creating without a user binds the creator, unassigning binds the editor, and there is no fallback to the account owner - a token with no bound user is rejected like an invalid one.
  • Member-bound: a 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 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 a token whose assigned member is neither a current member nor the owner of the account.
  • An open popout WebSocket is re-validated every 30 seconds and closed with TOKEN_REVOKED once its origin token is revoked, expired or deleted (or its member left), and with TOKEN_EXPIRED when its session JWT expires. See Credential re-validation.
  • Signing out of the webapp (POST /api/auth/logout, also used by the footer's Manage Cookies → Authentication) deletes lumio-popout-token and lumio-popout-origin along with the login cookies, so pop-out sessions opened in that browser end too. Both cookies are host-only and are deleted without a domain. An OBS browser source is a separate browser and keeps its session.

Login Connections​

Login connections link OAuth provider identities to Lumio users. Each connection stores:

  • Provider name and provider account ID
  • Username, display name, avatar URL
  • Encrypted OAuth tokens (access_token, refresh_token)
  • Scopes and token expiry

Supported providers: Twitch, YouTube (via Google), Discord, and Kick.

Discord is a sign-in-only provider. Its login connection cannot be assigned to a channel. In Security → Sign-in it shows Sign-in only instead of channel chips or + Channel; the add-connection dialog offers no channel selection for Discord. The personal name/avatar source can still be a Discord login connection.

Display name & avatar source​

A user's Lumio display_name and avatar_url are derived from one of their login connections - the name source - not entered as free text. The user picks the source connection (persisted as users.name_source_connection_id); when none is picked it defaults to their first-ever-connected login (login_connections.created_at ascending). On every login the name/avatar are re-mirrored from the resolved source, so signing in through a secondary platform never changes them.

Change the source via PUT /v1/users/me/name-source-connection (REST) or the setNameSourceConnection mutation (GraphQL) - both ownership-checked against the caller's own connections and session-only. The current source is exposed as name_source_kind + name_source_connection_id on GET /users/me / me. A channel's name and picture are separate: they come from one of the channel's own channel connections or its own details, never from a login connection (see Accounts › Channel name and picture).

Own details (name_source_kind = 'own', ZAF-2096). The same mutation with kind: "own" switches to the user's own details; the current name and picture stay as the starting values and sign-ins no longer overwrite them (find_or_create_user_by_provider keeps both when the kind is own). E-mail-only profiles are always own. With own details, updateDisplayName / PATCH /v1/users/me/display-name sets the name (trimmed, NFC, 2-32 characters, no control or invisible characters). The picture is uploaded via REST-only POST /v1/users/me/avatar and removed via removeAvatar / DELETE /v1/users/me/avatar. The server checks the size (5 MB), the magic bytes (PNG/JPEG/WebP) and the pixel count (16 MP) itself, then re-encodes the picture as metadata-free 512 px and 128 px WebP in lo-storage (users/{user_id}/avatar/…, key prefix in users.avatar_storage_key). Switching back to a connection, removing the picture, a replacement and the profile deletion all delete the files. The GDPR "user data only" export contains the picture file. At most two profile or channel pictures are decoded at a time across the API; a further upload gets 503 AVATAR_BUSY with Retry-After. Every change writes user:profile_identity_changed. See the REST reference.

Key Files​

FilePurpose
crates/lo-auth/src/lib.rsPublic API re-exports
crates/lo-auth/src/jwt.rsJWT creation, validation, refresh token generation
crates/lo-auth/src/context.rsAuthContext enum with permission checks
crates/lo-auth/src/api_key.rsAPI key generation, hashing, type identification
crates/lo-auth/src/popout_token.rsPopout token generation and validation
crates/lo-auth/src/rbac.rsPermission constants, default roles
crates/lo-auth/src/error.rsAuth error types
apps/api/src/graphql/auth.rsGraphQL mutations (exchange, refresh, logout) and me query
apps/api/src/db/auth.rsUser, session, and login connection DB operations