Skip to main content

OAuth Provider Setup

Sign-in​

The ID app offers Twitch, Kick, Google, Discord and verified email/password sign-in. Registration uses email and password; an address is usable only after the person clicks Confirm on the emailed link. The link does not sign anyone in. A matching email claim from a platform does not automatically link profiles.

If 2FA is enabled, the ID app offers a passkey, authenticator app code or recovery code after the password or platform callback and before the API creates a session or PKCE authorization code. A trusted device can bypass this prompt for 30 days. Native desktop and mobile flows finish on /login/done, which offers Back to the app if the browser did not open the app automatically. The callback is allowlisted and the app name comes from a fixed list.

Adding a platform login connection from Security → Sign-in requires a fresh confirmation on the existing session. Reconnecting the same provider account is exempt. The ID app returns a limited link_result value to the dashboard, which displays the outcome and clears the URL parameters.

This guide walks you through creating OAuth applications for each platform. Lumio uses distinct credential sources for login, channel, and bot flows. They are stored in different places and are not interchangeable:

Login credentialsSystem channel credentialsSystem bot credentialsAccount channel/bot credentials
PurposeUser authentication ("Sign in with Twitch")Lumio-managed channel OAuth appsLumio-managed bot OAuth appsPer-account platform API access (chat, events, moderation)
Stored inapps/id/.env.local and apps/api/config/local.toml[auth] *_channel_client_id / *_channel_client_secret keys in the API[auth] *_bot_client_id / *_bot_client_secret keys in the APIThe app_credentials database table (AES-256-GCM encrypted)
Entered byThe operator, once, in config filesThe operator, once per environment / provider appThe operator, once per environment / provider appEach account, in Dashboard → Connections → the platform tab → App
ScopesUser-level, defined in apps/id/src/lib/providers.tsChannel scopes, defined in apps/api/src/platforms.rsBot scopes, defined in apps/api/src/platforms.rsChannel/bot scopes, defined in apps/api/src/platforms.rs
Resulting recordlogin_connectionschannel_connections, pinned credential_source = "system"bot_connections, pinned credential_source = "system"channel_connections or bot_connections, pinned credential_source = "account"
caution

Login credentials are not channel credentials, and channel credentials are not bot credentials. System channel and bot credentials are the global Lumio OAuth apps used only by connections pinned to system; account credentials are used only by connections pinned to account. The login scopes in apps/id/src/lib/providers.ts are user-level - they are not a subset of the channel scopes and must not be stripped on the assumption that they belong on the channel side.

A third surface - bot connections - is covered in Bot Connections at the end of this guide.

Redirect URI Registration Matrix​

System-sourced channel connections, account bot connections, and admin global-bot connections use the unified web-host callback:

{web.public_url}/api/connections/oauth/{platform}/callback

The callback must be served by the same web host the user is signed in to: for channel and account-bot connects the API completes the exchange only for the browser session that started the connect (the web callback forwards that session), so a callback on another host - without the session cookie - fails with 401. The admin global-bot flow needs no session on the web host.

For production Twitch/Kick system apps, register:

https://lumio.vision/api/connections/oauth/{platform}/callback

For staging, register:

https://lumio.web.staging.zaflun.dev/api/connections/oauth/{platform}/callback

For local development, register:

http://localhost:4000/api/connections/oauth/{platform}/callback

The server dispatches the callback by the OAuth state handle's kind (channel, bot or global_bot; the Shopify app uses no OAuth callback) and return_to. Account/BYO channel connections and degraded bot flows still use source-specific legacy callback URLs because their authorize path emits the legacy state format.

Login apps​

Login OAuth belongs to the ID app. These apps are separate from system channel/bot apps.

PlatformProduction redirect URIStaging redirect URILocal redirect URINotes
Twitch loginhttps://id.lumio.vision/api/auth/callback/twitchhttps://lumio.id.staging.zaflun.dev/api/auth/callback/twitchhttp://localhost:4002/api/auth/callback/twitchTwitch allows multiple redirect URIs per app, so the login pair can also serve as the Twitch channel fallback when the channel pair is unset and the app also registers the unified web callback.
YouTube / Google loginhttps://id.lumio.vision/api/auth/callback/googlehttps://lumio.id.staging.zaflun.dev/api/auth/callback/googlehttp://localhost:4002/api/auth/callback/googleThe ID app provider slug is google; channel connections use the Lumio platform slug youtube.
Kick loginhttps://id.lumio.vision/api/auth/callback/kickhttps://lumio.id.staging.zaflun.dev/api/auth/callback/kickhttp://localhost:4002/api/auth/callback/kickKick accepts up to 10 redirect URIs per app, so one login app can register production, staging and local login callbacks. Keep the login app separate from the system channel/bot apps by design — it lives in the ID-app config with user-level scopes.

System channel and bot apps​

System OAuth app pairs live in the API [auth] config. Channel apps use *_channel_client_id / *_channel_client_secret; dedicated bot apps use *_bot_client_id / *_bot_client_secret. The Channel app and Bot app for the same provider register the same callback in a given environment; the encrypted state handle selects whether Lumio redeems the code with the channel, account-bot, or admin global-bot secret pair.

Platform / appProduction redirect URIStaging redirect URILocal redirect URINotes
Twitch Channel apphttps://lumio.vision/api/connections/oauth/twitch/callbackhttps://lumio.web.staging.zaflun.dev/api/connections/oauth/twitch/callbackhttp://localhost:4000/api/connections/oauth/twitch/callbackTwitch supports multiple redirect URIs on one app. A single Twitch Channel app can carry production, staging and local callbacks when that matches the operator model.
Twitch Bot apphttps://lumio.vision/api/connections/oauth/twitch/callbackhttps://lumio.web.staging.zaflun.dev/api/connections/oauth/twitch/callbackhttp://localhost:4000/api/connections/oauth/twitch/callbackDedicated bot keys are preferred. If absent, bot connects fall back to the Twitch channel pair, then to the Twitch login pair.
Kick Channel apphttps://lumio.vision/api/connections/oauth/kick/callbackhttps://lumio.web.staging.zaflun.dev/api/connections/oauth/kick/callbackhttp://localhost:4000/api/connections/oauth/kick/callbackKick accepts up to 10 redirect URIs per app. A single Kick Channel app can carry production, staging and local callbacks when that matches the operator model.
Kick Bot apphttps://lumio.vision/api/connections/oauth/kick/callbackhttps://lumio.web.staging.zaflun.dev/api/connections/oauth/kick/callbackhttp://localhost:4000/api/connections/oauth/kick/callbackKeep the Bot app on a separate client_id from the Channel app for quota/suspension isolation, not because of any redirect-URI limit. Both apps register the same environment callback.
YouTube Bot apphttps://lumio.vision/api/connections/oauth/youtube/callbackhttps://lumio.web.staging.zaflun.dev/api/connections/oauth/youtube/callbackhttp://localhost:4000/api/connections/oauth/youtube/callbackYouTube channel connections remain BYO/account. The system callback is only for an operator-managed YouTube bot app.

Account/BYO callbacks​

Account-owned credentials are entered per account in Dashboard → Connections → the platform tab → App and are stored in the database. These callbacks are legacy/BYO callbacks: register them only for account-managed connections, for local setups intentionally using account credentials, or for platforms that have degraded from system to account because no system pair is configured.

Platform / flowProduction redirect URIStaging redirect URILocal redirect URINotes
Twitch BYO channelhttps://lumio.vision/api/connections/channel/twitch/callbackhttps://lumio.web.staging.zaflun.dev/api/connections/channel/twitch/callbackhttp://localhost:4000/api/connections/channel/twitch/callbackNeeded only when Twitch channel connects use account credentials.
YouTube BYO channelhttps://lumio.vision/api/connections/channel/youtube/callbackhttps://lumio.web.staging.zaflun.dev/api/connections/channel/youtube/callbackhttp://localhost:4000/api/connections/channel/youtube/callbackYouTube channel connections are BYO/account; the platform slug is youtube, not google.
Kick BYO channelhttps://lumio.vision/api/connections/channel/kick/callbackhttps://lumio.web.staging.zaflun.dev/api/connections/channel/kick/callbackhttp://localhost:4000/api/connections/channel/kick/callbackNeeded only when Kick channel connects use account credentials.
Spotify BYO channelhttps://lumio.vision/api/connections/channel/spotify/callbackhttps://lumio.web.staging.zaflun.dev/api/connections/channel/spotify/callbackhttp://127.0.0.1:4000/api/connections/channel/spotify/callbackSpotify rejects localhost; Lumio rewrites localhost to 127.0.0.1 only for Spotify local callbacks.
Account bot legacy callbackhttps://lumio.vision/api/connections/bot/{platform}/callbackhttps://lumio.web.staging.zaflun.dev/api/connections/bot/{platform}/callbackhttp://localhost:4000/api/connections/bot/{platform}/callbackUsed only when an account bot flow degrades to account credentials instead of using a system bot/channel pair.
Admin global-bot legacy callbackhttps://admin.lumio.vision/api/bot-connections/{platform}/callbackhttps://lumio.admin.staging.zaflun.dev/api/bot-connections/{platform}/callbackhttp://localhost:4001/api/bot-connections/{platform}/callbackKept for legacy/in-flight global bot authorizations. Current system global bots return through the web-host unified callback and then redirect back to Admin via return_to.

Production needs four system apps for the Twitch and Kick system-managed paths: one Channel app and one Bot app per platform. Because both Twitch and Kick accept multiple redirect URIs per app, those same four apps also cover staging and local — register each environment's callback on the corresponding app. Add apps only if you deliberately want separate client_ids per environment for blast-radius isolation.

PlatformProduction system appsStaging system appsSystem callback
Twitch2 apps: Channel + Botsame 2 appshttps://lumio.vision/api/connections/oauth/twitch/callback and https://lumio.web.staging.zaflun.dev/api/connections/oauth/twitch/callback
Kick2 apps: Channel + Botsame 2 appsone callback per environment, all registered on the same two apps: .../api/connections/oauth/kick/callback

Kick accepts up to 10 redirect URIs per OAuth app (verified in the Kick developer dashboard, 2026-09-23; Kick's public documentation does not state the limit). One Kick app can therefore carry production, staging and local callbacks (three of the ten slots). Its Channel app and Bot app each register the same environment callback, for example https://lumio.vision/api/connections/oauth/kick/callback; the state.kind value selects whether Lumio uses the channel secret pair or the bot secret pair. Keeping Channel and Bot on separate apps is a quota/suspension-isolation choice, not a redirect-URI requirement. The login app is kept separate by design (it lives in the ID-app config with user-level scopes), but when no dedicated Kick Channel app is configured, Kick channel connects fall back to the login pair (kick_client_id / kick_client_secret) with a one-time warning (ZAF-1493) — the login app can register the channel callback in one of its ten redirect-URI slots.

YouTube bot credentials have dedicated [auth] key slots too (youtube_bot_client_id / youtube_bot_client_secret), but YouTube channel connections stay BYO/account and there is no YouTube channel system app to fall back to. Without the YouTube bot keys, the YouTube bot flow degrades to account.


Twitch​

Developer Console​

https://dev.twitch.tv/console

Login Credentials (ID App)​

  1. Go to Applications → Register Your Application
  2. Set Name to something like Lumio Login
  3. Set OAuth Redirect URL to: {ID_APP_URL}/api/auth/callback/twitch
    • Local: http://localhost:4002/api/auth/callback/twitch
  4. Set Category to Website Integration
  5. Copy Client ID and generate a Client Secret
  6. Add to apps/id/.env.local:
    AUTH_TWITCH_ID=your_client_id
    AUTH_TWITCH_SECRET=your_client_secret

Login Scopes (requested automatically):

ScopePurpose
openidOpenID Connect authentication
user:read:emailRead user email
user:read:emotesRead user emotes
chat:readRead chat messages
chat:editSend chat messages
user:write:chatWrite chat messages
user:read:subscriptionsRead user subscriptions
moderator:read:chattersRead chatter list
moderator:manage:chat_messagesDelete chat messages
moderator:manage:banned_usersBan/unban users

Username resolution. Twitch's OIDC claim set carries no display_name — only preferred_username, the lowercase login. Right after the OAuth handshake the ID App's Twitch profile() therefore calls Helix GET /users with the user's fresh access token to recover the real (possibly localized) display name. name then carries the display name and preferred_username the login, so the login connection stores them in the correct display_name / username columns and the users row gets a distinct display_name and login. Without this lookup both would be the lowercase login, and every Twitch user's Lumio display name (sidebar, account switcher, profile) would show as their login. If the Helix call fails we fall back to the OIDC login for both.

Channel Credentials​

Twitch channel connections default to system. Configure a global Twitch channel app in the API [auth] config (twitch_channel_client_id / twitch_channel_client_secret) and register the unified callback:

{web.public_url}/api/connections/oauth/twitch/callback

Twitch allows multiple redirect URIs on one app, so if the channel-specific pair is empty, Lumio may use the Twitch login pair as the system pair. If no global pair is available, new Twitch channel connects degrade to the account-credential path and use the legacy account callback:

{WEB_URL}/api/connections/channel/twitch/callback

Channel Scopes (33 scopes, requested automatically):

ScopePurpose
channel:read:subscriptionsRead subscriber list and events
channel:read:redemptionsRead channel point redemptions
channel:manage:redemptionsManage channel point redemptions
channel:read:hype_trainRead hype train events
channel:read:pollsRead poll data
channel:manage:pollsCreate and manage polls
channel:read:predictionsRead prediction data
channel:manage:predictionsCreate and manage predictions
channel:read:goalsRead channel goals
bits:readRead cheer/bits events
moderator:read:followersRead follower list
moderator:read:suspicious_usersRead suspicious user flags
moderator:manage:suspicious_usersUpdate suspicious user treatment
moderator:manage:banned_usersBan and unban users
channel:botSend chat messages as the channel bot
user:read:chatRead chat messages
channel:read:adsRead ad schedule
channel:manage:raidsCancel raids (Lumio does not start raids)
channel:moderateModerate chat
moderator:read:blocked_termsRead blocked terms
moderator:read:chat_settingsRead chat settings
moderator:read:unban_requestsRead unban requests
moderator:read:banned_usersRead banned users list
moderator:read:chat_messagesRead chat messages as moderator
moderator:read:warningsRead user warnings
moderator:read:moderatorsRead moderator list
moderator:read:vipsRead VIP list
moderator:read:chattersRead the connected chatters (Helix GET /chat/chatters) for the chatter list
moderation:readRead the moderator list (Helix Get Moderators) — the chatter list's moderator group
channel:read:vipsRead the VIP list (Helix Get VIPs) — the chatter list's VIP group
moderator:manage:automodReceive held AutoMod messages and decisions
moderator:manage:blocked_termsRequested for later blocked-term management; currently unused
moderator:manage:automod_settingsRequested for later AutoMod-level management; currently unused

Reconnect an existing Twitch channel once to grant the AutoMod scopes. Lumio requests all three together; held messages need moderator:manage:automod, while the two management scopes are not used yet.

Bot Credentials​

Twitch bot connections default to system. Configure a separate Twitch bot app in [auth] with twitch_bot_client_id / twitch_bot_client_secret and register the same unified callback as the channel app:

{web.public_url}/api/connections/oauth/twitch/callback

If the Twitch bot pair is empty, the bot flow falls back to the Twitch channel pair, then to the Twitch login pair, and logs a one-time warning. If neither bot nor channel/login keys are available, the bot flow degrades to account.


YouTube / Google​

Developer Console​

https://console.cloud.google.com/apis/credentials

Prerequisites​

  1. Create a Google Cloud project (or use an existing one)
  2. Enable YouTube Data API v3 under APIs & Services > Library
  3. Configure OAuth consent screen under APIs & Services > OAuth consent screen
    • User Type: External (or Internal for Google Workspace)
    • Add the scopes listed below
    • Add test users if app is in "Testing" status

Login Credentials (ID App)​

  1. Go to APIs & Services > Credentials → Create Credentials → OAuth 2.0 Client ID
  2. Application type: Web application
  3. Set Authorized redirect URI to: {ID_APP_URL}/api/auth/callback/google
    • Local: http://localhost:4002/api/auth/callback/google
  4. Copy Client ID and Client Secret
  5. Add to apps/id/.env.local:
    AUTH_GOOGLE_ID=your_client_id
    AUTH_GOOGLE_SECRET=your_client_secret

Login Scopes:

ScopePurpose
openidOpenID Connect authentication
emailUser email address
profileUser name and profile picture
youtube.readonlyRead YouTube channel data - also used at login to fetch snippet.customUrl for the username
youtube.force-sslYouTube Data API access (live chat)

Username resolution. Right after the OAuth handshake the ID App calls youtube/v3/channels?part=snippet&mine=true with the user's fresh access token and stores snippet.customUrl (e.g. cruex_de) as the login connection's username. Without this lookup Google's OIDC profile only exposes email (typically a …@pages.plusgoogle.com pseudo-email), which would never match the YouTube display name a viewer actually @-mentions in chat. If the YouTube call fails (no channel attached to the Google account, scope revoked) we fall back to email. The handle drives both the multichat @-mention highlighter and the broadcaster-status fallback that unlocks moderation buttons before the broadcaster's first chat message.

Channel Credentials​

YouTube channel connections are BYO/account. Each account supplies its own Google Cloud OAuth client.

  1. Create a separate OAuth 2.0 Client ID in the same Google Cloud project
  2. Set Authorized redirect URI to: {WEB_URL}/api/connections/channel/youtube/callback
    • Local: http://localhost:4000/api/connections/channel/youtube/callback
    • The channel-connection platform slug is youtube, not google - only the ID App's login callback uses google.
  3. Users enter their own Client ID + Secret in Dashboard → Connections → the platform tab → App

Channel Scopes (2 required scopes, 1 optional scope requested on every connection):

ScopePurpose
youtube.readonly (required)Read channel, video, and live chat data
youtube.force-ssl (required)Full YouTube Data API access (chat, comments)
yt-analytics-monetary.readonly (optional)Read the channel's estimated revenue from YouTube Analytics

YouTube Analytics API​

To show YouTube Analytics revenue in Stream History, prepare the Google Cloud project that holds the channel connection's OAuth client:

  1. Enable YouTube Analytics API under APIs & Services → Library, alongside YouTube Data API v3.
  2. On the OAuth consent screen, add https://www.googleapis.com/auth/yt-analytics-monetary.readonly under Data Access. The App Credentials wizard marks this scope optional and provides the full name to copy.
  3. If the app is in Testing, add the channel owner as a test user. For an external app in production, Google may show an unverified app warning until its sensitive scopes are verified. Google's Testing refresh tokens can expire after seven days; publish the app and reconnect to obtain a production grant.
  4. Connect or reconnect YouTube in Manage → Connections. Leave Google's checkbox for viewing revenue selected. Lumio requests the optional scope on every connect, but Google lets you decline it. The connect dialog explains that members with the View revenue permission (history:revenue-read) can see the YouTube amounts.

YouTube reports revenue only for channels in the YouTube Partner Program. Figures normally arrive 2–3 days after a stream and may be corrected before they become final; until they arrive, the report shows the Lumio estimate. If the scope was declined, the channel is outside the Partner Program, or the API is disabled, the revenue area explains the next step. The optional scope does not create a general connection warning. The Analytics requests use the quota of your own Google app; Lumio makes at most one bundled revenue request per channel per day, usually none.

info

Both login and channel credentials request access_type=offline and prompt=consent to ensure refresh tokens are always issued.

Bot Credentials​

YouTube bot connections have a system credential mode but no channel-system app to fall back to. Configure youtube_bot_client_id / youtube_bot_client_secret in [auth] and register the unified callback when the deployment uses an operator-managed YouTube bot app:

{web.public_url}/api/connections/oauth/youtube/callback

If the YouTube bot pair is empty, the YouTube bot flow degrades to account.


Discord​

Developer Console​

https://discord.com/developers/applications

Login Credentials​

Discord has no channel connection. It is a sign-in-only provider for users and a separate server-invite platform for Lumio's global bot. A Discord login cannot be assigned to a channel. The login and bot applications use separate credentials; the bot itself authenticates with a static token saved in Admin → Providers.

  1. Go to Applications → New Application
  2. Under OAuth2, copy Client ID and Client Secret
  3. Add Redirect URL: {ID_APP_URL}/api/auth/callback/discord
    • Local: http://localhost:4002/api/auth/callback/discord
  4. Add to apps/id/.env.local:
    AUTH_DISCORD_ID=your_client_id
    AUTH_DISCORD_SECRET=your_client_secret

Login Scopes:

ScopePurpose
identifyRead user identity
emailRead user email

The API needs the same Discord login Client ID and Secret under [auth].discord_client_id and [auth].discord_client_secret to validate the token audience and refresh login tokens. Discord refresh tokens rotate; keep the secret in the API environment as well as the ID app.

Discord Bot App​

Use a Discord application for the one global Lumio bot. Set [auth].discord_bot_client_id and [auth].discord_bot_client_secret in the API environment, then save its Bot Token under Admin → Providers → Discord. The Application ID and Secret are not entered in the admin form.

Register these bot-app OAuth redirects in the Discord Developer Portal:

https://lumio.vision/api/connections/oauth/discord/callback
https://lumio.web.staging.zaflun.dev/api/connections/oauth/discord/callback
http://localhost:4000/api/connections/oauth/discord/callback

Enable Requires OAuth2 Code Grant for the bot application and set its Install Link to None. Lumio builds the invite itself with bot applications.commands identify, integration_type=0, and permissions 2147576832 (View Channels, Send Messages, Manage Messages, Embed Links, Read Message History, Use Application Commands). Never grant Administrator (permissions=8). identify makes Discord return a code and the confirmed server in the token response; Lumio revokes that user token after verification. The inviter needs Manage Server on the target server.

If connecting a server ends with Discord did not confirm the server invitation, the API log names the cause. Discord rejected the guild invite code exchange carries Discord's error: invalid_client means [auth].discord_bot_client_secret is not the Client Secret of the app in discord_bot_client_id (the Bot Token is a different value), and invalid_grant means the redirect URL does not match or the code was already used. The bot itself authenticates with its own DISCORD_BOT__BOT__TOKEN; a gateway close 4004 Authentication failed means that token is wrong or was reset in the portal.

Enable the privileged Server Members Intent for member events and Message Content Intent for message-based features in the Bot settings. Discord requires verification once the bot reaches 100 servers. If either privileged intent is off, the bot reports DisallowedGatewayIntents on startup.

[auth]
discord_bot_client_id = "..."
discord_bot_client_secret = "..."

Issuer: the Discord provider in apps/id/src/lib/providers.ts sets issuer: "https://discord.com". Discord appends the RFC 9207 iss=https://discord.com parameter to the OAuth callback, and Auth.js rejects any iss that differs from the provider's issuer — without the explicit value it expects its fallback https://authjs.dev. The setting triggers no OIDC discovery, because the authorization, token and userinfo endpoints are explicit URLs. Kick sets no issuer: it sends no iss today.

Troubleshooting unexpected "iss"​

If Discord sign-in and linking Discord from Security → Sign-in both end on /login?error=Configuration, and the ID app log shows a CallbackRouteError with unexpected "iss" (issuer) response parameter value (expected: https://authjs.dev), the Discord provider is running without its issuer. The callback carries iss=https://discord.com and fails before the sign-in or link step runs. Restore issuer: "https://discord.com" on the Discord provider in apps/id/src/lib/providers.ts; the regression test apps/id/__tests__/lib/providers.test.ts fails when it is missing. The Discord Developer Portal has no setting for this.


Kick​

Kick runs OAuth 2.1. Its /oauth/authorize mandates both PKCE (code_challenge + code_challenge_method=S256) and a state parameter, even for confidential clients that also send a client_secret; omitting either makes id.kick.com answer with a 404 (not a 400), which reads like a broken URL rather than a protocol error. Its /oauth/token expects the client credentials as body parameters (client_secret_post), not an Authorization: Basic header — the same auth style our Rust token-refresh uses for Kick. This applies to every Kick app you create below.

Lumio sends code_challenge + code_challenge_method=S256 and state on the authorize URL and the matching code_verifier on the token exchange for all Kick flows (login, channel, bot, and the admin global bot). For the API-driven channel/bot flows PKCE is generated server-side per authorize request; the login flow (apps/id, NextAuth) pins checks: ["pkce", "state"] on the Kick provider so no @auth/core default change can silently drop state. No extra configuration is required.

Developer Console​

https://kick.com/settings/developer

Login Credentials (ID App)​

  1. Create a new application in the Kick Developer settings
  2. Set Redirect URL to: {ID_APP_URL}/api/auth/callback/kick
    • Local: http://localhost:4002/api/auth/callback/kick
  3. Copy Client ID and Client Secret
  4. Add to apps/id/.env.local:
    AUTH_KICK_ID=your_client_id
    AUTH_KICK_SECRET=your_client_secret
  5. Set the same client_id as kick_client_id in the API [auth] config (see API Server below). Kick is a first-class login provider on every token-exchange path (POST /v1/auth/token, POST /v1/auth/authorize, GraphQL exchangeToken, POST /v1/auth/link), and each validates the token audience against kick_client_id. If it is unset, Kick login — not just linking — fails closed (see the mandatory audience-validation note in the API Server section).

Login Scopes:

ScopePurpose
user:readRead user profile
chat:writeSend chat messages
moderation:banBan, time out and unban users in the multichat and on the user info card
moderation:chat_message:manageDelete chat messages

Kick chat moderation (delete, timeout, ban) runs on the acting user's personal Kick login token, the same way Twitch moderation runs on the Twitch login token, so the moderation scopes sit on the login app. The API checks the stored login scopes before it calls Kick; a missing scope comes back as login_scope_missing and the dashboard shows "log in with Kick again". Kick only grants new scopes on a fresh authorize: a user who logged in with Kick before the moderation scopes were requested must log in with Kick once more (or use Reconnect on the Kick login connection under Account → Profile) to grant them. No migration is needed.

User profile lookup: after the token exchange the ID app reads the signed-in user from Kick's official user route, GET https://api.kick.com/public/v1/users (no id query = the token owner). That response is an envelope wrapping an array — { "data": [ { "user_id", "name", "email", "profile_picture" } ], "message" } — so the provider unwraps data[0] and maps user_id → account id (stable, required), name → display name and login name, email, and profile_picture → avatar. The legacy unofficial https://api.kick.com/api/v1/user route returns 404 and must not be used.

Channel Credentials​

Kick channel connections default to system. Create a Kick Channel app and register the unified callback for each environment it serves — one app can carry production, staging and local, because Kick accepts up to 10 redirect URIs per app:

{web.public_url}/api/connections/oauth/kick/callback

Set kick_channel_client_id and kick_channel_client_secret in the API [auth] config to give Kick channel connects a dedicated client_id. When those are unset, Kick channel connects fall back to the Kick login pair (kick_client_id / kick_client_secret), mirroring Twitch — Kick accepts up to 10 redirect URIs per app, so the login app can register the channel callback above alongside the login callback. The fallback logs a one-time warning so you register a dedicated channel app (every provider rate limit and subscription cap is scoped to the client_id). Only when neither the channel pair nor the login pair is configured do new connects degrade to the account-credential path and use the legacy account callback:

{WEB_URL}/api/connections/channel/kick/callback

Channel Scopes (6 scopes):

ScopePurpose
user:readRead user profile information
channel:readRead channel information
events:readRead channel events
events:subscribeSubscribe to real-time events
chat:writeSend chat messages
moderation:chat_message:manageDelete chat messages

Bot Credentials​

Kick bot connections default to system. Create a Kick Bot app — kept on a separate client_id from the Channel app for quota/suspension isolation, not because of any redirect-URI limit — register the same unified callback as the Kick Channel app, and set kick_bot_client_id / kick_bot_client_secret in the API [auth] config. Both the Channel app and the Bot app register the same callback:

{web.public_url}/api/connections/oauth/kick/callback

If the Kick bot pair is empty, the bot flow falls back to the Kick channel pair and logs a one-time warning. If neither pair is available, the bot flow degrades to account.

Bot Scopes (2 scopes):

ScopePurpose
user:readRead the bot account's own profile — used right after the exchange to resolve the bot's display name via GET /public/v1/users. Required: without it the identity lookup returns 403 and the bot saves without a name.
chat:writeSend chat messages as the bot
note

Adding user:read to the Kick bot scope set (ZAF-1510) re-consents existing Kick bot connections: Kick only grants the wider scope on a fresh authorize, so a bot connected before this change keeps its old grant until it is reconnected. No migration is needed — the next bot connect picks up user:read automatically.

Troubleshooting a rejected token exchange​

A 401 at https://id.kick.com/oauth/token with error: invalid_client means the client_id / client_secret pair Lumio presented is rejected by Kick — the secret is wrong or was rotated. It is not a redirect_uri, PKCE, or expired-code problem (those return 400 invalid_grant / invalid_request). The fix is a setup action: regenerate the secret in the Kick Developer console and update the matching [auth] key, or confirm you pasted the right app's pair when running separate Channel, Bot, and Login apps.

Surrounding whitespace is not a cause: Lumio strips leading and trailing whitespace from every configured client_id / client_secret — the system [auth] keys and the per-account BYO app_credentials alike — on load, so a copy-pasted trailing newline or stray space can no longer produce this silent 401. When it trims a system key it emits exactly one startup warn naming the config key (never the value), e.g. OAuth credential carried surrounding whitespace and was trimmed on load, config_key=auth.kick_channel_client_secret; re-set that key cleanly to silence it.

Lumio surfaces this without leaking secrets. The dashboard shows Kick rejected the token exchange: invalid_client (the OAuth code, never the raw upstream body), and the API log emits one warn naming which app was used:

platform=kick credential_source=system client_id_hint=...6789
status=401 Unauthorized oauth_error=invalid_client
oauth_error_description="Client authentication failed"

credential_source (system / account) plus the last-4 client_id_hint identify the app per failing request, so you never have to grep the boot log for the one-time channel-uses-login-pair fallback warning: a channel connect that fell back to the login pair (see above) is still system, but its client_id_hint is the login app's id, not the channel app's.

To confirm a pair without waiting for a connect attempt, POST the client_credentials grant to the same endpoint with the pair in question:

curl -s -i -X POST https://id.kick.com/oauth/token \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=client_credentials' \
--data-urlencode 'client_id=…' --data-urlencode 'client_secret=…'

200 with an access_token means the pair is valid; 401 invalid_client means it is not.

Webhooks (event delivery)​

The Kick channel app requests user:read, channel:read, events:subscribe, chat:write, moderation:chat_message:manage, kicks:read, channel:rewards:read and ads:read. The last three support KICKs, channel-point redemptions and ad-break status respectively. A connection lacking any of these scopes lists them in missing_scopes until the owner reconnects; no migration is needed. Ad-break status is polled only while a stream is live and once at finalization, not delivered by webhook. Full list: Channel Connections & OAuth Scopes.

Real-time events (follows, subs, resubs, gift subs, KICKs, channel-points redemptions, bans, livestream status and metadata) reach Lumio via Kick webhooks. Lumio subscribes to the events automatically (POST /public/v1/events/subscriptions, using the events:subscribe channel scope above), but Kick has no API to set the delivery URL — it is a manual per-app setting:

  1. Open kick.com/settings/developer → Edit app for the Kick Channel app.
  2. Turn on the "Enable Webhooks" toggle.
  3. Set "Webhook URL" to your API's public receiver:
    • Production: https://api.lumio.vision/v1/webhooks/kick
    • Otherwise: {server.public_url}/v1/webhooks/kick (the exact value is logged once by the Kick worker at API start).
  4. No signing-key setup. The API fetches Kick's public RSA key itself from https://api.kick.com/public/v1/public-key (unauthenticated; the same key for every Kick app), caches it, and refreshes it every 6 h. The API host needs outbound HTTPS to api.kick.com. The check is fail-closed: if no key can be fetched and none is cached, every delivery returns 401 and the API logs an ERROR. Leave webhooks.kick_public_key empty. It is only an optional pin, for example for a host without egress, and when it is set, automatic rotation is off. Leave webhooks.kick_secret empty too. Kick does not issue a webhook shared secret, and the legacy HMAC value is not the OAuth Client Secret. Details: Kick Webhook Public Key.

Because the URL is a single per-app field, one Kick app serves one environment — a shared receiver for all environments is not possible with a single app. Without this step the OAuth flow still succeeds and subscriptions register, but no event is ever delivered (chat still works — the Kick bot reads chat over Kick's Pusher WebSocket, which needs no webhook).

Spotify​

Developer Console​

https://developer.spotify.com/dashboard

Channel Credentials Only​

Spotify is channel connection only - there is no Spotify login.

  1. Go to Dashboard → Create App
  2. Set Redirect URI to: {WEB_URL}/api/connections/channel/spotify/callback
    • Local: http://127.0.0.1:4000/api/connections/channel/spotify/callback
    • Spotify rejects localhost redirect URIs, so Lumio rewrites localhost → 127.0.0.1 for Spotify (and only Spotify) when building the callback URL. Register the 127.0.0.1 form.
  3. Under "Which API/SDKs are you planning to use?", select both Web API and Web Playback SDK
  4. Users enter their own Client ID + Secret in Dashboard → Connections → the platform tab → App

Channel Scopes (7 scopes):

ScopePurpose
user-read-playback-stateRead current playback state
user-modify-playback-stateControl playback (play, pause, skip)
user-read-currently-playingRead currently playing track
playlist-read-privateRead private playlists
playlist-read-collaborativeRead collaborative playlists
playlist-modify-publicModify public playlists
playlist-modify-privateModify private playlists
Spotify Auth Style

Spotify uses Authorization: Basic base64(client_id:client_secret) for token refresh, unlike other platforms which send credentials in the POST body.

Shopify​

Lumio is one public app in the Shopify App Store, a system app like the Twitch/Kick channel apps. Its credentials live only in the API config [shopify], never in app_credentials, and are never mixed with a login app. There is no Shopify login and no per-account Shopify app. How install and link work end to end: Shopify.

Dev Dashboard​

https://dev.shopify.com/dashboard — create the app under the zaflun organization with public distribution.

  • Embedded, Shopify-managed install. The app runs inside the Shopify admin (App Bridge ID tokens + token exchange; Shopify: Authenticate an embedded app without a template). The app URL (the embedded app apps/shopify on its own origin), the redirect URL Shopify requires, the scopes and the webhook subscriptions are declared in apps/shopify/shopify.app.<env>.toml and deployed with shopify app deploy; Lumio requests no scopes and has no OAuth callback.

  • Scopes: read_orders,read_products,read_legal_policies. The token exchange refuses a token whose scope readback lacks one of [shopify] scopes.

  • Protected customer data. Order webhooks are protected customer data, so request Level 1 access in the Partner Dashboard (API access requests → Protected customer data). Add the Name field (Level 2, with a data protection review) only if merchants should be able to show a customer label (customer_display); without it Shopify redacts the name and every label is empty. Development stores work without a review once the data and fields are selected.

  • Webhooks are app-level subscriptions in shopify.app.<env>.toml ({server.public_url}/v1/webhooks/shopify), signed with the app's client secret; every installed store sends them, and a store that is not linked to a Lumio account is dropped on arrival. See Webhooks → Shopify.

  • Compliance webhooks are app configuration, not a per-shop subscription. Set them in each environment file, apps/shopify/shopify.app.staging.toml and apps/shopify/shopify.app.production.toml, before the App Store review:

    [[webhooks.subscriptions]]
    compliance_topics = ["customers/data_request", "customers/redact", "shop/redact"]
    uri = "https://api.lumio.vision/v1/webhooks/shopify/compliance"

    The example uses production. Staging uses https://lumio.api.staging.zaflun.dev/v1/webhooks/shopify/compliance. Keep the app URL, client_id, scopes and all webhook URLs aligned with that environment, then run shopify app deploy --config staging or shopify app deploy --config production from apps/shopify. This releases Shopify's app configuration and extensions; the embedded web app is deployed separately. See Shopify → Compliance webhooks.

API config​

[shopify]
client_id = "…" # LUMIO__SHOPIFY__CLIENT_ID
# client_secret: ENV only # LUMIO__SHOPIFY__CLIENT_SECRET
# client_secret_previous: ENV only, during a rotation # LUMIO__SHOPIFY__CLIENT_SECRET_PREVIOUS
api_version = "2026-10" # LUMIO__SHOPIFY__API_VERSION
scopes = "read_orders,read_products,read_legal_policies"
app_store_url = "" # the App Store listing, once published

With client_id or client_secret empty every Shopify surface answers Shopify is not configured…, and with webhooks.require_signatures = true every Shopify webhook is refused.

To rotate the client secret, set the new one as client_secret and the old one as client_secret_previous until Shopify no longer signs with the old one. The steps are in Shopify → Client-secret rotation.


Where to Configure​

Login credentials must be set in two places - the ID App's .env.local and the API's config/local.toml. The API needs them for login token refresh and Twitch chat API calls.

1. ID App (apps/id/.env.local)​

Used by NextAuth for the OAuth login flow:

# NextAuth
AUTH_SECRET=generate-a-random-secret-here
AUTH_URL=http://localhost:4002

# Twitch
AUTH_TWITCH_ID=
AUTH_TWITCH_SECRET=

# Discord
AUTH_DISCORD_ID=
AUTH_DISCORD_SECRET=

# Google / YouTube
AUTH_GOOGLE_ID=
AUTH_GOOGLE_SECRET=

# Kick
AUTH_KICK_ID=
AUTH_KICK_SECRET=

2. API Server (apps/api/config/local.toml)​

local.toml is git-ignored and is the last file layer before environment variables (default.toml → {env}.toml → local.toml → ENV). The API uses these for login token refresh and Twitch chat operations. They must match the ID App's credentials:

[auth]
# Same credentials as AUTH_TWITCH_ID / AUTH_TWITCH_SECRET in ID App
twitch_client_id = "..."
twitch_client_secret = "..."
# Same credentials as AUTH_GOOGLE_ID / AUTH_GOOGLE_SECRET in ID App
google_client_id = "..."
google_client_secret = "..."
# Same credentials as AUTH_DISCORD_ID / AUTH_DISCORD_SECRET in ID App.
discord_client_id = "..."
discord_client_secret = "..."
# Same credentials as AUTH_KICK_ID / AUTH_KICK_SECRET in ID App
kick_client_id = "..."
kick_client_secret = "..."
# Global system Channel apps. Both Twitch and Kick fall back to the login pair
# above when their channel pair is unset (ZAF-1493); Kick accepts up to 10 redirect
# URIs per app, so the login app can also register the channel callback.
twitch_channel_client_id = "..."
twitch_channel_client_secret = "..."
kick_channel_client_id = "..."
kick_channel_client_secret = "..."

# Dedicated global system Bot apps. If one of these pairs is empty, that bot
# flow falls back to the platform's channel pair and logs a warning. If neither
# bot nor channel keys are configured, the bot flow degrades to account/BYO.
twitch_bot_client_id = "..."
twitch_bot_client_secret = "..."
kick_bot_client_id = "..."
kick_bot_client_secret = "..."
youtube_bot_client_id = "..."
youtube_bot_client_secret = "..."
discord_bot_client_id = "..."
discord_bot_client_secret = "..."

# AES-256-GCM key used to encrypt OAuth tokens and credentials at rest.
# MANDATORY outside development — the API refuses to boot in staging/production if
# this is empty/placeholder/< 16 chars, and CHANGING it makes every existing
# ciphertext permanently undecryptable. See installation.md → "Mandatory auth secrets".
# Generate with: openssl rand -base64 32
token_encryption_key = "..."

Every one of these keys can also be supplied as an environment variable, which wins over the file: LUMIO__AUTH__TWITCH_CLIENT_ID, LUMIO__AUTH__TWITCH_CHANNEL_CLIENT_ID, LUMIO__AUTH__TWITCH_BOT_CLIENT_ID, LUMIO__AUTH__DISCORD_CLIENT_ID, LUMIO__AUTH__DISCORD_CLIENT_SECRET, LUMIO__AUTH__DISCORD_BOT_CLIENT_ID, LUMIO__AUTH__DISCORD_BOT_CLIENT_SECRET, LUMIO__AUTH__TOKEN_ENCRYPTION_KEY, and so on.

warning

The credentials in apps/api/config/local.toml must be identical to those in apps/id/.env.local. The API uses them to refresh login tokens - if they don't match, token refresh will fail silently and users will be logged out.

[auth].discord_client_id validates the Discord login token audience. The matching discord_client_secret lets the API refresh that user's rotating login token. The separate discord_bot_client_id / discord_bot_client_secret pair signs the verified server invite; neither is an account-owned app credential.

Audience validation is mandatory (fail-closed)

On every token exchange (POST /v1/auth/token, POST /v1/auth/authorize, GraphQL exchangeToken, POST /v1/auth/link) the API validates both the token's subject (the claimed user) and its audience - the OAuth client the token was minted for. The audience each provider reports must match the configured client id:

ProviderAudience sourceSubject sourceMust match
Twitchclient_id from GET /oauth2/validatesame responsetwitch_client_id
Googleaud from /tokeninfosame responsegoogle_client_id
Discordapplication.id from /oauth2/@mesame responsediscord_client_id
Kickclient_id from POST /public/v1/token/introspectuser_id from GET /public/v1/userskick_client_id

Kick is the only provider needing two requests: no single Kick endpoint carries both the subject and the audience. Its introspect route is flagged deprecated in Kick's OpenAPI but has no announced successor - if Kick removes it, Kick logins fail closed rather than skipping the check.

This blocks a confused-deputy attack where an attacker registers their own OAuth app, gets a victim to authorize it, and replays the victim-subject token against Lumio (ZAF-1015). The check is fail-closed: if a provider's client id is unset, token exchange for that provider fails (HTTP 500 / INTERNAL_SERVER_ERROR) rather than skipping the check. A foreign-audience token is rejected as an invalid credential (HTTP 401, audited as user:login_failed), identically to a subject mismatch. Set all four client ids in any deployment that serves logins for that provider.

System app environment variables​

Config keyEnvironment variable
twitch_channel_client_idLUMIO__AUTH__TWITCH_CHANNEL_CLIENT_ID
twitch_channel_client_secretLUMIO__AUTH__TWITCH_CHANNEL_CLIENT_SECRET
kick_channel_client_idLUMIO__AUTH__KICK_CHANNEL_CLIENT_ID
kick_channel_client_secretLUMIO__AUTH__KICK_CHANNEL_CLIENT_SECRET
twitch_bot_client_idLUMIO__AUTH__TWITCH_BOT_CLIENT_ID
twitch_bot_client_secretLUMIO__AUTH__TWITCH_BOT_CLIENT_SECRET
kick_bot_client_idLUMIO__AUTH__KICK_BOT_CLIENT_ID
kick_bot_client_secretLUMIO__AUTH__KICK_BOT_CLIENT_SECRET
youtube_bot_client_idLUMIO__AUTH__YOUTUBE_BOT_CLIENT_ID
youtube_bot_client_secretLUMIO__AUTH__YOUTUBE_BOT_CLIENT_SECRET

3. Account Credentials (Database)​

Account channel/bot credentials are not configured in files. Each user enters their own Client ID + Secret via Dashboard → Connections → the platform tab → App. They are stored AES-256-GCM encrypted in the app_credentials table, one row per (account, platform). The resulting access/refresh tokens land in channel_connections or bot_connections, also encrypted.


Bot Connections​

Bot connections let Lumio post to chat with a bot identity instead of the channel identity. Twitch, YouTube, and Kick use OAuth bot tokens; Spotify has no bot connection. Discord uses a static global bot token plus a separate OAuth app for verified server invites.

OAuth bot connections default to the system credential source and use dedicated bot app keys from [auth]: twitch_bot_client_id, kick_bot_client_id, or youtube_bot_client_id plus the matching secret. Register the unified web-host callback for system bot flows:

{web.public_url}/api/connections/oauth/{platform}/callback

The admin global-bot flow starts in Admin → Providers, returns through the web host, and redirects back to the admin Providers page through the state handle's return_to. For Discord, save the static global bot token in Admin → Providers and configure the invite app pair under [auth] as described above.

When a deployment has no global key pair for a bot platform, the account bot authorize path degrades to the legacy account source and uses:

{WEB_URL}/api/connections/bot/{platform}/callback
PlatformBot scopes
Twitchchat:read, chat:edit, user:write:chat, user:bot, user:read:chat
YouTubeyoutube.force-ssl
Kickchat:write
Discord server invitebot, applications.commands, identify

If a Twitch or Kick dedicated bot pair is unset but its channel pair exists, the bot flow uses the channel keys and logs a warning. For those OAuth bots, missing global keys can fall back to account, which is the normal local development path. Discord has no per-account bot app: an unset discord_bot_client_* pair makes server invites fail closed.

Kick production verification now applies to both production Kick apps. The Bot app is the one whose chat.message.sent subscription cap matters for chat sending: unverified apps are capped at 1,000 subscriptions for that event type, verified apps at 10,000.

See Bot Connections for the full flow.

See Also​