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 credentials | System channel credentials | System bot credentials | Account channel/bot credentials | |
|---|---|---|---|---|
| Purpose | User authentication ("Sign in with Twitch") | Lumio-managed channel OAuth apps | Lumio-managed bot OAuth apps | Per-account platform API access (chat, events, moderation) |
| Stored in | apps/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 API | The app_credentials database table (AES-256-GCM encrypted) |
| Entered by | The operator, once, in config files | The operator, once per environment / provider app | The operator, once per environment / provider app | Each account, in Dashboard → Connections → the platform tab → App |
| Scopes | User-level, defined in apps/id/src/lib/providers.ts | Channel scopes, defined in apps/api/src/platforms.rs | Bot scopes, defined in apps/api/src/platforms.rs | Channel/bot scopes, defined in apps/api/src/platforms.rs |
| Resulting record | login_connections | channel_connections, pinned credential_source = "system" | bot_connections, pinned credential_source = "system" | channel_connections or bot_connections, pinned credential_source = "account" |
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.
| Platform | Production redirect URI | Staging redirect URI | Local redirect URI | Notes |
|---|---|---|---|---|
| Twitch login | https://id.lumio.vision/api/auth/callback/twitch | https://lumio.id.staging.zaflun.dev/api/auth/callback/twitch | http://localhost:4002/api/auth/callback/twitch | Twitch 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 login | https://id.lumio.vision/api/auth/callback/google | https://lumio.id.staging.zaflun.dev/api/auth/callback/google | http://localhost:4002/api/auth/callback/google | The ID app provider slug is google; channel connections use the Lumio platform slug youtube. |
| Kick login | https://id.lumio.vision/api/auth/callback/kick | https://lumio.id.staging.zaflun.dev/api/auth/callback/kick | http://localhost:4002/api/auth/callback/kick | Kick 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 / app | Production redirect URI | Staging redirect URI | Local redirect URI | Notes |
|---|---|---|---|---|
| Twitch Channel app | https://lumio.vision/api/connections/oauth/twitch/callback | https://lumio.web.staging.zaflun.dev/api/connections/oauth/twitch/callback | http://localhost:4000/api/connections/oauth/twitch/callback | Twitch 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 app | https://lumio.vision/api/connections/oauth/twitch/callback | https://lumio.web.staging.zaflun.dev/api/connections/oauth/twitch/callback | http://localhost:4000/api/connections/oauth/twitch/callback | Dedicated bot keys are preferred. If absent, bot connects fall back to the Twitch channel pair, then to the Twitch login pair. |
| Kick Channel app | https://lumio.vision/api/connections/oauth/kick/callback | https://lumio.web.staging.zaflun.dev/api/connections/oauth/kick/callback | http://localhost:4000/api/connections/oauth/kick/callback | Kick 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 app | https://lumio.vision/api/connections/oauth/kick/callback | https://lumio.web.staging.zaflun.dev/api/connections/oauth/kick/callback | http://localhost:4000/api/connections/oauth/kick/callback | Keep 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 app | https://lumio.vision/api/connections/oauth/youtube/callback | https://lumio.web.staging.zaflun.dev/api/connections/oauth/youtube/callback | http://localhost:4000/api/connections/oauth/youtube/callback | YouTube 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 / flow | Production redirect URI | Staging redirect URI | Local redirect URI | Notes |
|---|---|---|---|---|
| Twitch BYO channel | https://lumio.vision/api/connections/channel/twitch/callback | https://lumio.web.staging.zaflun.dev/api/connections/channel/twitch/callback | http://localhost:4000/api/connections/channel/twitch/callback | Needed only when Twitch channel connects use account credentials. |
| YouTube BYO channel | https://lumio.vision/api/connections/channel/youtube/callback | https://lumio.web.staging.zaflun.dev/api/connections/channel/youtube/callback | http://localhost:4000/api/connections/channel/youtube/callback | YouTube channel connections are BYO/account; the platform slug is youtube, not google. |
| Kick BYO channel | https://lumio.vision/api/connections/channel/kick/callback | https://lumio.web.staging.zaflun.dev/api/connections/channel/kick/callback | http://localhost:4000/api/connections/channel/kick/callback | Needed only when Kick channel connects use account credentials. |
| Spotify BYO channel | https://lumio.vision/api/connections/channel/spotify/callback | https://lumio.web.staging.zaflun.dev/api/connections/channel/spotify/callback | http://127.0.0.1:4000/api/connections/channel/spotify/callback | Spotify rejects localhost; Lumio rewrites localhost to 127.0.0.1 only for Spotify local callbacks. |
| Account bot legacy callback | https://lumio.vision/api/connections/bot/{platform}/callback | https://lumio.web.staging.zaflun.dev/api/connections/bot/{platform}/callback | http://localhost:4000/api/connections/bot/{platform}/callback | Used only when an account bot flow degrades to account credentials instead of using a system bot/channel pair. |
| Admin global-bot legacy callback | https://admin.lumio.vision/api/bot-connections/{platform}/callback | https://lumio.admin.staging.zaflun.dev/api/bot-connections/{platform}/callback | http://localhost:4001/api/bot-connections/{platform}/callback | Kept 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.
| Platform | Production system apps | Staging system apps | System callback |
|---|---|---|---|
| Twitch | 2 apps: Channel + Bot | same 2 apps | https://lumio.vision/api/connections/oauth/twitch/callback and https://lumio.web.staging.zaflun.dev/api/connections/oauth/twitch/callback |
| Kick | 2 apps: Channel + Bot | same 2 apps | one 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
Login Credentials (ID App)
- Go to Applications → Register Your Application
- Set Name to something like
Lumio Login - Set OAuth Redirect URL to:
{ID_APP_URL}/api/auth/callback/twitch- Local:
http://localhost:4002/api/auth/callback/twitch
- Local:
- Set Category to
Website Integration - Copy Client ID and generate a Client Secret
- Add to
apps/id/.env.local:AUTH_TWITCH_ID=your_client_idAUTH_TWITCH_SECRET=your_client_secret
Login Scopes (requested automatically):
| Scope | Purpose |
|---|---|
openid | OpenID Connect authentication |
user:read:email | Read user email |
user:read:emotes | Read user emotes |
chat:read | Read chat messages |
chat:edit | Send chat messages |
user:write:chat | Write chat messages |
user:read:subscriptions | Read user subscriptions |
moderator:read:chatters | Read chatter list |
moderator:manage:chat_messages | Delete chat messages |
moderator:manage:banned_users | Ban/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):
| Scope | Purpose |
|---|---|
channel:read:subscriptions | Read subscriber list and events |
channel:read:redemptions | Read channel point redemptions |
channel:manage:redemptions | Manage channel point redemptions |
channel:read:hype_train | Read hype train events |
channel:read:polls | Read poll data |
channel:manage:polls | Create and manage polls |
channel:read:predictions | Read prediction data |
channel:manage:predictions | Create and manage predictions |
channel:read:goals | Read channel goals |
bits:read | Read cheer/bits events |
moderator:read:followers | Read follower list |
moderator:read:suspicious_users | Read suspicious user flags |
moderator:manage:suspicious_users | Update suspicious user treatment |
moderator:manage:banned_users | Ban and unban users |
channel:bot | Send chat messages as the channel bot |
user:read:chat | Read chat messages |
channel:read:ads | Read ad schedule |
channel:manage:raids | Cancel raids (Lumio does not start raids) |
channel:moderate | Moderate chat |
moderator:read:blocked_terms | Read blocked terms |
moderator:read:chat_settings | Read chat settings |
moderator:read:unban_requests | Read unban requests |
moderator:read:banned_users | Read banned users list |
moderator:read:chat_messages | Read chat messages as moderator |
moderator:read:warnings | Read user warnings |
moderator:read:moderators | Read moderator list |
moderator:read:vips | Read VIP list |
moderator:read:chatters | Read the connected chatters (Helix GET /chat/chatters) for the chatter list |
moderation:read | Read the moderator list (Helix Get Moderators) — the chatter list's moderator group |
channel:read:vips | Read the VIP list (Helix Get VIPs) — the chatter list's VIP group |
moderator:manage:automod | Receive held AutoMod messages and decisions |
moderator:manage:blocked_terms | Requested for later blocked-term management; currently unused |
moderator:manage:automod_settings | Requested 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
- Create a Google Cloud project (or use an existing one)
- Enable YouTube Data API v3 under APIs & Services > Library
- 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)
- Go to APIs & Services > Credentials → Create Credentials → OAuth 2.0 Client ID
- Application type: Web application
- Set Authorized redirect URI to:
{ID_APP_URL}/api/auth/callback/google- Local:
http://localhost:4002/api/auth/callback/google
- Local:
- Copy Client ID and Client Secret
- Add to
apps/id/.env.local:AUTH_GOOGLE_ID=your_client_idAUTH_GOOGLE_SECRET=your_client_secret
Login Scopes:
| Scope | Purpose |
|---|---|
openid | OpenID Connect authentication |
email | User email address |
profile | User name and profile picture |
youtube.readonly | Read YouTube channel data - also used at login to fetch snippet.customUrl for the username |
youtube.force-ssl | YouTube 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.
- Create a separate OAuth 2.0 Client ID in the same Google Cloud project
- 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, notgoogle- only the ID App's login callback usesgoogle.
- Local:
- 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):
| Scope | Purpose |
|---|---|
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:
- Enable YouTube Analytics API under APIs & Services → Library, alongside YouTube Data API v3.
- On the OAuth consent screen, add
https://www.googleapis.com/auth/yt-analytics-monetary.readonlyunder Data Access. The App Credentials wizard marks this scope optional and provides the full name to copy. - 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.
- 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.
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.
- Go to Applications → New Application
- Under OAuth2, copy Client ID and Client Secret
- Add Redirect URL:
{ID_APP_URL}/api/auth/callback/discord- Local:
http://localhost:4002/api/auth/callback/discord
- Local:
- Add to
apps/id/.env.local:AUTH_DISCORD_ID=your_client_idAUTH_DISCORD_SECRET=your_client_secret
Login Scopes:
| Scope | Purpose |
|---|---|
identify | Read user identity |
email | Read 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)
- Create a new application in the Kick Developer settings
- Set Redirect URL to:
{ID_APP_URL}/api/auth/callback/kick- Local:
http://localhost:4002/api/auth/callback/kick
- Local:
- Copy Client ID and Client Secret
- Add to
apps/id/.env.local:AUTH_KICK_ID=your_client_idAUTH_KICK_SECRET=your_client_secret - Set the same
client_idaskick_client_idin 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, GraphQLexchangeToken,POST /v1/auth/link), and each validates the token audience againstkick_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:
| Scope | Purpose |
|---|---|
user:read | Read user profile |
chat:write | Send chat messages |
moderation:ban | Ban, time out and unban users in the multichat and on the user info card |
moderation:chat_message:manage | Delete 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):
| Scope | Purpose |
|---|---|
user:read | Read user profile information |
channel:read | Read channel information |
events:read | Read channel events |
events:subscribe | Subscribe to real-time events |
chat:write | Send chat messages |
moderation:chat_message:manage | Delete 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):
| Scope | Purpose |
|---|---|
user:read | Read 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:write | Send chat messages as the bot |
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:
- Open kick.com/settings/developer → Edit app for the Kick Channel app.
- Turn on the "Enable Webhooks" toggle.
- 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).
- Production:
- 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 toapi.kick.com. The check is fail-closed: if no key can be fetched and none is cached, every delivery returns401and the API logs anERROR. Leavewebhooks.kick_public_keyempty. It is only an optional pin, for example for a host without egress, and when it is set, automatic rotation is off. Leavewebhooks.kick_secretempty 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.
- Go to Dashboard → Create App
- 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
localhostredirect URIs, so Lumio rewriteslocalhost→127.0.0.1for Spotify (and only Spotify) when building the callback URL. Register the127.0.0.1form.
- Local:
- Under "Which API/SDKs are you planning to use?", select both Web API and Web Playback SDK
- Users enter their own Client ID + Secret in Dashboard → Connections → the platform tab → App
Channel Scopes (7 scopes):
| Scope | Purpose |
|---|---|
user-read-playback-state | Read current playback state |
user-modify-playback-state | Control playback (play, pause, skip) |
user-read-currently-playing | Read currently playing track |
playlist-read-private | Read private playlists |
playlist-read-collaborative | Read collaborative playlists |
playlist-modify-public | Modify public playlists |
playlist-modify-private | Modify private playlists |
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/shopifyon its own origin), the redirect URL Shopify requires, the scopes and the webhook subscriptions are declared inapps/shopify/shopify.app.<env>.tomland deployed withshopify 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.tomlandapps/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 runshopify app deploy --config stagingorshopify app deploy --config productionfromapps/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.
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.
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:
| Provider | Audience source | Subject source | Must match |
|---|---|---|---|
| Twitch | client_id from GET /oauth2/validate | same response | twitch_client_id |
aud from /tokeninfo | same response | google_client_id | |
| Discord | application.id from /oauth2/@me | same response | discord_client_id |
| Kick | client_id from POST /public/v1/token/introspect | user_id from GET /public/v1/users | kick_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 key | Environment variable |
|---|---|
twitch_channel_client_id | LUMIO__AUTH__TWITCH_CHANNEL_CLIENT_ID |
twitch_channel_client_secret | LUMIO__AUTH__TWITCH_CHANNEL_CLIENT_SECRET |
kick_channel_client_id | LUMIO__AUTH__KICK_CHANNEL_CLIENT_ID |
kick_channel_client_secret | LUMIO__AUTH__KICK_CHANNEL_CLIENT_SECRET |
twitch_bot_client_id | LUMIO__AUTH__TWITCH_BOT_CLIENT_ID |
twitch_bot_client_secret | LUMIO__AUTH__TWITCH_BOT_CLIENT_SECRET |
kick_bot_client_id | LUMIO__AUTH__KICK_BOT_CLIENT_ID |
kick_bot_client_secret | LUMIO__AUTH__KICK_BOT_CLIENT_SECRET |
youtube_bot_client_id | LUMIO__AUTH__YOUTUBE_BOT_CLIENT_ID |
youtube_bot_client_secret | LUMIO__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
| Platform | Bot scopes |
|---|---|
| Twitch | chat:read, chat:edit, user:write:chat, user:bot, user:read:chat |
| YouTube | youtube.force-ssl |
| Kick | chat:write |
| Discord server invite | bot, 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
- OAuth Credentials Architecture - Dual-credential design, encryption, token refresh
- Channel Connections & Scopes - Scope details, connection flow, storage
- Token Refresh - Automatic token refresh worker