Skip to content

Discord #737

Open
Open

Description

@ngoiyaeric

Clerk + Discord OAuth is a great fit for this use case. When users sign up/sign in via Discord, Clerk automatically creates (or links) a SaaS user account for them. This is standard social login behavior in Clerk—no extra "upon joining" logic needed for basic auth.

1. Enable Discord in Clerk (Quick Setup)

Development Instance (easiest for testing)

  • Go to your Clerk Dashboard → SSO connections.
  • Click Add connection → For all users → Select Discord.
  • Enable it (Clerk uses shared credentials—no extra config needed).

Production Instance

  1. In Clerk Dashboard → SSO connections → Add Discord connection.

  2. Toggle on Enable for sign-up and sign-in and Use custom credentials.

  3. Copy the Redirect URI shown (e.g., https://your-domain.accounts.dev/sign-in or similar for production).

  4. Create Discord OAuth App:

    • Go to Discord Developer Portal.
    • New Application → Go to OAuth2 tab.
    • Add the Redirect URI from Clerk under Redirects.
    • Copy Client ID and Client Secret (reset secret if needed).
  5. Paste Client ID + Secret back into Clerk and save.

Test via your app's Account Portal (sign-in page) or <SignIn /> component.

2. Frontend Integration (Next.js / React example)

Assuming you already have Clerk set up:

import { SignIn } from "@clerk/nextjs";

export default function Page() {
  return <SignIn path="/sign-in" routing="path" signUpUrl="/sign-up" />;
}

Discord button appears automatically once enabled. Users signing in with Discord become SaaS users in Clerk (with externalAccounts linked to their Discord profile).

Clerk handles:

  • Sign-up / sign-in equivalence (new users auto-created).
  • User metadata (name, email, avatar from Discord).
  • Session management, JWTs, etc.

3. Backend / User Management

  • Use Clerk's Backend API or SDK to fetch user details: clerkClient.users.getUser(userId).
  • Access Discord-linked data via user.externalAccounts.
  • Webhooks (recommended for SaaS): Listen to user.created, user.updated to sync to your database (e.g., create a profile, assign default role, send welcome email).

4. Advanced: Discord Guild/Member Features

If you want to:

  • Check if the user is in your Discord server (guild).
  • Sync roles, grant access on join, or manage members.

After auth:

  • Get the OAuth access token from Clerk: const { getToken } = useAuth(); then request Discord token with appropriate scopes.
  • Use Discord API endpoints (/users/@me/guilds, etc.) with the token.

Scopes (add in Clerk when using custom credentials):

  • Default: identify email
  • Extra for guilds: guilds (list servers), guilds.members.read (member details).

Example flow from community projects: After Clerk sign-in, use server actions to call Discord API for role management.

Bot Integration Tip: For automatic role assignment on server join, combine this with a Discord bot that listens for member joins and checks against your Clerk user database (via Discord ID).

Common Gotchas

  • Redirect URI mismatches → Exact match required (including protocol, no trailing slashes issues). Use Clerk's provided one.
  • Production vs Dev: Always use custom credentials in prod.
  • Rate limits / permissions: Be careful with Discord API calls.
  • Existing users: Allow linking Discord in user profile if they signed up another way.

Next Steps / Resources

  • Official: Clerk Discord Guide.
  • Clerk + Next.js social connections overview.
  • For full Discord member/role management examples, check tutorials using Clerk + Discord API (e.g., Brian Morrison's projects).

If you share more details (tech stack like Next.js, what "upon joining" specifically means—e.g., auto-role, server invite, paid gating), I can give more targeted code/webhook examples or help debug!

Activity

  1. ngoiyaeric commented on Jul 14, 2026

    @ngoiyaeric
    CollaboratorAuthor
  2. coderabbitai commented on Jul 14, 2026

    @coderabbitai
    Contributor

    Coding Plan

    Summary

    • An always-on, fully event-driven discord.js bot (hosted off Vercel) reacts to guildMemberAdd and automatically DMs joiners a static link to a dedicated /auth/discord route, gracefully handling blocked/closed DMs; Discord never appears on the app's /sign-in, /sign-up, or modal.
    • The /auth/discord route auto-triggers Clerk's oauth_discord strategy via authenticateWithRedirect with an explicit post-auth destination; a /sso-callback route completes sign-in. Discord IS configured as a Clerk social connection but hidden from the default UI, so account creation/sign-in happens the standard Clerk-OAuth way.
    • Join context is correlated to the authenticated user via the shared Discord providerUserId on Clerk externalAccounts; a per-join token can be threaded through the flow (documented additive path) if stronger anti-abuse/binding is required.
    • Post-login, the app reads the Discord identity from Clerk externalAccounts (extending the existing webhook/resolveClerkUserToDbUser sync), optionally persisting discord_id/discord_username and mirroring to Supabase; the bot assigns guild roles using its bot token, optionally gated by a read-only bot→app status check.
    Design Choices
    Design Choice 1: How does the bot direct joiners to authenticate, and how is the bot-originated join context correlated with the OAuth result?

    Options Considered:

    1. DM a static/dedicated app link (e.g. ${APP_BASE_URL}/auth/discord) that itself auto-triggers the Discord OAuth flow. Correlation between the join and the resulting user is established implicitly by Clerk: because auth uses the oauth_discord strategy, the authenticated Clerk user's externalAccounts[provider==='discord'].providerUserId IS the same Discord user ID the bot saw in guildMemberAdd. No separate join token is minted; the Discord ID is the shared correlation key.
    2. Mint a per-join, single-use, expiring token, embed it in the DM link, carry it through the OAuth flow (via redirectUrl/redirectUrlComplete query param or Clerk sign-in metadata), and validate/consume it server-side on the callback to bind the join event to the authenticated user and add anti-abuse (link cannot be forwarded/replayed, expires quickly).

    Chosen Option: 1

    Rationale: A static link with Discord-ID-based correlation requires no token table, no bot→app minting API, and no custom binding logic — Clerk's oauth_discord flow guarantees the authenticated user carries the same Discord providerUserId the bot already holds, which is sufficient for downstream role assignment. Because the link only initiates a standard OAuth consent (which itself requires the user to authenticate with Discord), the abuse surface is low: a forwarded link merely lets another Discord user start their own legitimate OAuth flow. If stronger anti-abuse or explicit join-to-user binding is later required, Task 2.2/2.3 document exactly where a per-join token would be threaded through (redirectUrl state → validated/consumed in the callback), so Option 2 remains a clean, additive upgrade rather than a rewrite.

    Design Choice 2: Should Discord be configured as a Clerk social connection at all, given it must not appear on the default sign-in UI?

    Options Considered:

    1. Discord SHOULD be configured as a Clerk social connection (added in the Clerk Dashboard, with custom credentials in production), but with "Enable for sign-up and sign-in" DISABLED so it is treated as link-only and does not render in the prebuilt <SignIn />/<SignUp /> UI or modal; it is invoked exclusively through a custom signIn.authenticateWithRedirect({ strategy: 'oauth_discord', ... }) call.
    2. Do not create a Clerk Discord connection and instead implement a fully custom Discord OAuth flow outside Clerk.

    Chosen Option: 1

    Rationale: Discord absolutely IS a Clerk social connection — that is what lets Clerk natively create/link the user and populate externalAccounts. The only thing suppressed is its visibility on the default UI surfaces. Per Clerk's docs, disabling "Enable for sign-up and sign-in" makes the provider link-only in the prebuilt components while still allowing custom OAuth flows via authenticateWithRedirect, which cleanly satisfies "configured in Clerk, but never shown on the default surfaces." Option 2 would discard Clerk's native user creation/externalAccounts and is unnecessary.

    Design Choice 3: Where should Discord identity come from and be persisted?

    Options Considered:

    1. Source Discord identity from Clerk's native externalAccounts (populated automatically by the OAuth flow) and optionally mirror it into the users table.
    2. Maintain a custom discord_id linking flow driven by the bot/verification token.

    Chosen Option: 1

    Rationale: Because sign-in now happens the standard Clerk-OAuth way, the Discord identity (provider: 'discord', providerUserId, username, imageUrl, approvedScopes) is already present on the Clerk user's externalAccounts; the app reads it from there rather than maintaining a separate linking flow.

    Design Choice 4: How does the bot perform guild role assignment after a user authenticates?

    Options Considered:

    1. The bot assigns roles directly via its bot token/Discord API, keying off the Discord user ID it already has from the guildMemberAdd event (optionally confirming "has this Discord ID authenticated?" via a thin read-only bot→app status call sourced from Clerk externalAccounts).
    2. Fetch a per-user Discord OAuth token from Clerk and call the Discord API server-side.

    Chosen Option: 1

    Rationale: The bot is already a guild member with the Discord user ID from the join event, so it can assign roles directly; any bot↔app communication is limited to a read-only "is this Discord ID linked/authenticated?" check, not identity linking.

    💡 User Tips

    Regenerate the plan with different choices with @coderabbitai <feedback>.

    Implementation Steps

    Phase 1: Discord bot deployment & configuration

    This phase implements the always-on bot that automatically (on guild join, not via commands) DMs the joiner a link to the dedicated app auth route, and is configured with the correct intents/permissions. The bot performs join detection and messaging only — it does not mint tokens or link identities.

    Build and host the always-on Discord bot

    Create a persistent bot service that automatically reacts to new members joining the guild and DMs them the auth link.

    • Create a standalone bot (new top-level directory, e.g. discord-bot/, or a separate deployable service) using discord.js v14 with the privileged GuildMembers intent enabled; keep intents minimal (only what is needed for guildMemberAdd and role assignment). The bot is fully event-driven — there is NO slash-command/manual trigger; onboarding fires automatically on join.
    • Subscribe to the guildMemberAdd event; on every join, automatically DM the new member a static link to the dedicated app auth route (${APP_BASE_URL}/auth/discord) via GuildMember#send().
    • Explicitly handle blocked/closed DMs as a graceful failure step: wrap GuildMember#send() in .catch(), log the failure, and do not crash the bot when a member has DMs disabled, has blocked the bot, or otherwise cannot receive the message (optionally fall back to a public welcome-channel mention if configured).
    • Deploy as a persistent process separate from Vercel — either a new service in docker-compose.yaml (alongside the existing app/db/qdrant services) or a Fly.io/Railway deployment; document setup (create Discord application + bot in the Discord Developer Portal, obtain the bot token, enable the Members intent, invite the bot to the guild with role-management permission).
    • Introduce env vars following the SCREAMING_SNAKE_CASE/provider-prefix convention: DISCORD_BOT_TOKEN, DISCORD_GUILD_ID, the app base URL used to build the auth link, and (only if the optional role-status check in Phase 3 is implemented) DISCORD_BOT_API_SECRET. Add new secrets to .env.local.example.
    🤖 Prompt for AI agents
    Implement Phase 1: build the always-on, fully event-driven Discord bot that
    automatically DMs joiners a link to the dedicated app auth route. The bot
    performs join detection and DM messaging only — it does not mint tokens or link
    identities, and there is no slash-command/manual trigger.
    
    - Create a standalone bot (new top-level directory, e.g. `discord-bot/`, or a
    separate deployable service) using discord.js v14 with the privileged
    `GuildMembers` intent enabled; keep intents minimal (only what is needed for
    `guildMemberAdd` and role assignment).
    - Subscribe to the `guildMemberAdd` event; on every join, automatically DM the
    new member a static link to the dedicated app auth route
    (`${APP_BASE_URL}/auth/discord`) via `GuildMember#send()`.
    - Explicitly handle blocked/closed DMs as a graceful failure step: wrap
    `GuildMember#send()` in `.catch()`, log the failure, and do not crash the bot
    when a member has DMs disabled, has blocked the bot, or otherwise cannot receive
    the message (optionally fall back to a public welcome-channel mention if
    configured).
    - Deploy as a persistent process separate from Vercel — either a new service in
    `docker-compose.yaml` (alongside the existing app/db/qdrant services) or a
    Fly.io/Railway deployment. Document setup: create the Discord application + bot
    in the Discord Developer Portal, obtain the bot token, enable the Members
    intent, invite the bot to the guild with role-management permission.
    - Introduce env vars following the `SCREAMING_SNAKE_CASE`/provider-prefix
    convention: `DISCORD_BOT_TOKEN`, `DISCORD_GUILD_ID`, the app base URL used to
    build the auth link, and (only if the optional role-status check in Phase 3 is
    implemented) `DISCORD_BOT_API_SECRET`. Add the new secrets to
    `.env.local.example`.
    

    Phase 2: Clerk Discord provider config + dedicated OAuth-trigger route & callback

    This phase configures Discord as a Clerk social connection that is hidden from the default sign-in/sign-up UI, and adds the dedicated route that auto-triggers the oauth_discord flow plus the callback route that completes sign-in and lands the user at the configured post-auth destination. Discord must never appear on /sign-in, /sign-up, or the existing modal.

    Task 1: Configure Discord as a Clerk social connection hidden from the default UI

    Enable Discord OAuth in Clerk (it IS a Clerk connection) without surfacing it on the prebuilt auth components.

    • In the Clerk Dashboard → SSO connections, add the Discord connection; in production use custom credentials (create a Discord OAuth app in the Developer Portal, register Clerk's redirect URI, set Client ID/Secret in Clerk), requesting the identify email scopes plus guilds/guilds.members.read only if needed downstream.
    • Disable "Enable for sign-up and sign-in" for the Discord connection so it is treated as link-only and does NOT render in the default <SignIn />/<SignUp /> components or the existing modal — Discord remains a fully configured Clerk connection but is invoked exclusively via the custom flow in Task 2.2.
    • Document this configuration explicitly as a decision (dashboard steps, redirect URI, scopes, and the fact that Discord IS connected in Clerk but intentionally hidden from default surfaces) alongside the bot setup docs.
    Task 2: Add the dedicated OAuth-trigger route

    Provide the bot-linked route that immediately starts the Discord OAuth flow and defines the post-auth destination.

    • Create app/auth/discord/page.tsx as a client component (following the 'use client' pattern of app/offline/page.tsx) that, on mount, calls Clerk's signIn.authenticateWithRedirect({ strategy: 'oauth_discord', redirectUrl: '/sso-callback', redirectUrlComplete: '<post-auth destination>' }).
    • Explicitly define and document the post-auth redirect destination (redirectUrlComplete) — default to the app home/dashboard (/), configurable via env/constant so onboarding lands users on the intended landing page after sign-in.
    • Document the extension point for carrying a per-join/verification token through the flow: if Option 2 from Assumption 1 is ever adopted, the token arrives as a query param on this route (e.g. /auth/discord?jt=...) and is threaded into the OAuth flow via redirectUrl state (e.g. /sso-callback?jt=...) so the callback can validate/consume it — the static-link default omits this but the route is structured to accept it additively.
    • This route is the ONLY entry point for Discord auth; it is reachable only via the bot's DM link and is not linked from /sign-in, /sign-up, or the modal.
    • The route inherits the global app/layout.tsx (ClerkProvider) and requires no middleware matcher change; render a lightweight "Redirecting to Discord…" UI while the redirect is initiated.
    Task 3: Add the Clerk OAuth callback route and document join-context correlation

    Complete the OAuth flow, route the user into the app, and record how the join event correlates to the authenticated user.

    • Create app/sso-callback/page.tsx rendering Clerk's <AuthenticateWithRedirectCallback /> to finish the OAuth return and establish the session, then land the user at the configured redirectUrlComplete destination (Task 2.2).
    • Document the correlation decision explicitly: the bot's guildMemberAdd Discord user ID and the authenticated Clerk user are correlated via the shared Discord providerUserId on externalAccounts (populated by the OAuth flow) — no separate join token is required for the default flow.
    • Document the anti-abuse posture and the additive path: the static link only initiates a standard Discord OAuth consent (which itself authenticates the actual Discord user), so replay/forwarding risk is low. If a per-join token is later threaded through (Assumption 1, Option 2), this callback is the place to validate/consume it (check expiresAt/consumedAt, bind the join to the resulting user) before honoring redirectUrlComplete.
    • Ensure this route inherits the global layout and requires no middleware change; new Clerk users created via Discord OAuth are handled natively by Clerk (no custom account-creation logic).
    🤖 Prompt for AI agents
    Implement Phase 2: configure Discord as a Clerk social connection that is hidden
    from the default sign-in/sign-up UI, and add the dedicated route that
    auto-triggers the `oauth_discord` flow plus the callback route that completes
    sign-in and lands the user at the configured post-auth destination. Discord must
    never appear on `/sign-in`, `/sign-up`, or the existing modal.
    
    - In the Clerk Dashboard → SSO connections, add the Discord connection; in
    production use custom credentials (create a Discord OAuth app in the Developer
    Portal, register Clerk's redirect URI, set Client ID/Secret in Clerk),
    requesting the `identify email` scopes plus `guilds`/`guilds.members.read` only
    if needed downstream. Disable "Enable for sign-up and sign-in" for the Discord
    connection so it is link-only and does NOT render in the default `<SignIn
    />`/`<SignUp />` components or the existing modal — Discord remains a fully
    configured Clerk connection but is invoked exclusively via the custom flow
    below. Document this configuration explicitly as a decision (dashboard steps,
    redirect URI, scopes, and the fact that Discord IS connected in Clerk but
    intentionally hidden from default surfaces) alongside the bot setup docs.
    - Create `app/auth/discord/page.tsx` as a client component (following the `'use
    client'` pattern of `app/offline/page.tsx`) that, on mount, calls Clerk's
    `signIn.authenticateWithRedirect({ strategy: 'oauth_discord', redirectUrl:
    '/sso-callback', redirectUrlComplete: '<post-auth destination>' })`. Explicitly
    define and document the post-auth redirect destination (`redirectUrlComplete`) —
    default to the app home/dashboard (`/`), configurable via env/constant. Document
    the extension point for carrying a per-join/verification token through the flow
    (query param on this route threaded into `redirectUrl` state for the callback to
    validate/consume) — the static-link default omits this but the route is
    structured to accept it additively. This route is the ONLY entry point for
    Discord auth; it is reachable only via the bot's DM link and is not linked from
    `/sign-in`, `/sign-up`, or the modal. It inherits the global `app/layout.tsx`
    (ClerkProvider) and requires no middleware matcher change; render a lightweight
    "Redirecting to Discord…" UI while the redirect is initiated.
    - Create `app/sso-callback/page.tsx` rendering Clerk's
    `<AuthenticateWithRedirectCallback />` to finish the OAuth return and establish
    the session, then land the user at the configured `redirectUrlComplete`
    destination. Document the correlation decision explicitly: the bot's
    `guildMemberAdd` Discord user ID and the authenticated Clerk user are correlated
    via the shared Discord `providerUserId` on `externalAccounts` (populated by the
    OAuth flow) — no separate join token is required for the default flow. Document
    the anti-abuse posture and the additive path: the static link only initiates a
    standard Discord OAuth consent, so replay/forwarding risk is low, but if a
    per-join token is later threaded through, this callback is where it would be
    validated/consumed before honoring `redirectUrlComplete`. Ensure this route
    inherits the global layout and requires no middleware change; new Clerk users
    created via Discord OAuth are handled natively by Clerk (no custom
    account-creation logic).
    

    Phase 3: Post-login identity sync, role assignment & visibility

    This phase covers everything after a user authenticates via Discord OAuth: reading the Discord identity from Clerk's externalAccounts, optionally persisting/mirroring it, bot-driven guild role assignment, and optional user-facing visibility. Identity is sourced from Clerk — there is no custom token/DB linking flow.

    Task 1: Sync Discord identity from Clerk externalAccounts into the app DB

    Read the Discord identity Clerk attaches during OAuth and persist it for downstream use.

    • Extend the Clerk sync paths to read the Discord externalAccount (where provider === 'discord'): update resolveClerkUserToDbUser/syncUserProfile in lib/auth/get-current-user.ts and the user.created/user.updated handler in app/api/clerk/webhook/route.ts (which today only read email/name/avatar) to also extract providerUserId (Discord ID), username, and imageUrl from currentUser().externalAccounts / evt.data.external_accounts.
    • Persist the extracted Discord identity onto the users row; do NOT implement any custom "link Discord ID to an already-authenticated user" flow — the identity is authoritative from Clerk's OAuth-created user.
    • Follow existing sync/action conventions (try/catch, console.error, best-effort profile refresh) already used in these files.
    Task 2: Extend the users schema with Discord identity fields

    Add persistent, queryable Discord identity columns populated from Clerk externalAccounts.

    • Update lib/db/schema.ts users table to add a discordId (discord_id) text column (unique), a discordUsername column, and reuse/extend the existing metadata jsonb for any variable Discord data (e.g. guild membership/roles).
    • Add a hand-written migration drizzle/migrations/0009_add_discord_account_link.sql following existing conventions: descriptive header comment, ADD COLUMN IF NOT EXISTS, unique constraint / CREATE INDEX IF NOT EXISTS idx_users_discord_id, and (if RLS applies) mirror the established is_clerk_user/clerk_id() policy patterns.
    • Values are written by the Task 3.1 sync logic sourced from Clerk externalAccounts, not from any bot-provided linking payload.
    Task 3: Optionally mirror the Discord identity to the Supabase backend

    Keep the Supabase "QCX-BACKEND" in sync if Discord identity is needed there for RLS/Realtime.

    • If the Discord link must be visible to Supabase, extend the sync_clerk_user RPC signature/body (in the same or a follow-on migration) to accept and persist discord_id/discord_username, and pass the new values from the existing Clerk webhook (app/api/clerk/webhook/route.ts) sync path.
    • Otherwise, explicitly scope Discord persistence to the primary Drizzle DB only and document that decision.
    Task 4: Bot-driven guild role assignment & optional onboarding/gating

    Grant Discord roles/access to authenticated members, driven by the bot.

    • Have the bot assign the configured role(s) to the guild member using its bot token and the Discord API (add-role on the GuildMember), keyed off the Discord user ID it already holds from guildMemberAdd; handle Discord rate limits and missing-permission cases gracefully.
    • If role assignment must be gated on "the user has actually authenticated in the app," add a thin, read-only bot→app status endpoint under app/api/discord/ (authenticated with the shared-secret DISCORD_BOT_API_SECRET header, mirroring the "handler enforces its own check" approach of app/api/clerk/webhook/route.ts) that answers whether a given Discord ID exists in the users table (sourced from the Clerk-synced discord_id). This bot↔app channel is for role/status only — never for identity linking.
    • Store configured role IDs and any gating rules as bot env/config.
    Task 5: Add a "Linked Accounts / Integrations" settings tab (optional)

    Give signed-in users visibility into their Discord link.

    • Add a new tab in components/settings/components/settings.tsx (Tabs.Trigger + Tabs.Content) backed by a new component (modeled on user-management-form.tsx) that shows Discord link status, guild membership/roles, and (optionally) a disconnect affordance.
    • Back the display with a noStore() read action (modeled on lib/actions/users.ts getSelectedModel) returning the current user's Discord identity (from the Clerk-synced columns) plus any guild/role data.
    • This tab is for viewing an already-authenticated Discord link; initial authentication still occurs exclusively via the bot-triggered /auth/discord route, never via a Discord button on the auth surfaces or modal.
    🤖 Prompt for AI agents
    Implement Phase 3: after a user authenticates via Discord OAuth, sync the
    Discord identity from Clerk's `externalAccounts` into the app DB, optionally
    mirror it to Supabase, drive guild role assignment from the bot, and optionally
    expose a settings view. There is no custom token/DB linking flow — identity is
    sourced authoritatively from Clerk.
    
    - Extend the Clerk sync paths to read the Discord `externalAccount` (where
    `provider === 'discord'`): update `resolveClerkUserToDbUser`/`syncUserProfile`
    in `lib/auth/get-current-user.ts` and the `user.created`/`user.updated` handler
    in `app/api/clerk/webhook/route.ts` (which today only read email/name/avatar) to
    also extract `providerUserId` (Discord ID), `username`, and `imageUrl` from
    `currentUser().externalAccounts` / `evt.data.external_accounts`. Persist the
    extracted Discord identity onto the `users` row; do NOT implement any custom
    "link Discord ID to an already-authenticated user" flow. Follow existing
    sync/action conventions (try/catch, `console.error`, best-effort profile
    refresh).
    - Update `lib/db/schema.ts`'s `users` table to add a `discordId` (`discord_id`)
    text column (unique) and a `discordUsername` column, and reuse/extend the
    existing `metadata jsonb` for variable Discord data (e.g. guild
    membership/roles). Add a hand-written migration
    `drizzle/migrations/0009_add_discord_account_link.sql` following existing
    conventions: descriptive header comment, `ADD COLUMN IF NOT EXISTS`, unique
    constraint / `CREATE INDEX IF NOT EXISTS idx_users_discord_id`, and, if RLS
    applies, mirror the established `is_clerk_user`/`clerk_id()` policy patterns.
    Values are written by the sync logic sourced from Clerk `externalAccounts`, not
    any bot-provided linking payload.
    - Decide whether the Discord link must also be mirrored to the Supabase
    "QCX-BACKEND": if so, extend the `sync_clerk_user` RPC signature/body (via the
    same or a follow-on migration) to accept and persist
    `discord_id`/`discord_username`, and pass the new values from the existing Clerk
    webhook sync path; otherwise explicitly scope Discord persistence to the primary
    Drizzle DB only and document that decision.
    - Have the bot assign configured role(s) to the guild member using its bot token
    and the Discord API (add-role on the `GuildMember`), keyed off the Discord user
    ID it already holds from `guildMemberAdd`, handling Discord rate limits and
    missing-permission cases gracefully. If role assignment must be gated on "the
    user has actually authenticated in the app," add a thin, read-only bot→app
    status endpoint under `app/api/discord/` (authenticated with the shared-secret
    `DISCORD_BOT_API_SECRET` header, mirroring the "handler enforces its own check"
    approach of `app/api/clerk/webhook/route.ts`) that answers whether a given
    Discord ID exists in the `users` table (sourced from the Clerk-synced
    `discord_id`). This bot↔app channel is for role/status only — never identity
    linking. Store configured role IDs and gating rules as bot env/config.
    - Optionally add a new "Linked Accounts / Integrations" tab in
    `components/settings/components/settings.tsx` using `Tabs.Trigger` +
    `Tabs.Content`, backed by a new component modeled on `user-management-form.tsx`,
    showing Discord link status, guild membership/roles, and optionally a disconnect
    affordance. Back it with a `noStore()` read action (modeled on
    `lib/actions/users.ts` `getSelectedModel`) returning the current user's Discord
    identity (from the Clerk-synced columns) plus any guild/role data. This tab is
    only for viewing an already-authenticated Discord link — initial authentication
    still happens exclusively via the bot-triggered `/auth/discord` route, never via
    a Discord button on the auth surfaces or modal.
    
    Research

    This is a Next.js (App Router) app using Clerk (@clerk/nextjs@^7.5.13, @clerk/backend@^3.11.0) for auth, wrapped globally via clerkMiddleware() in middleware.ts and <ClerkProvider> in app/layout.tsx. Authentication is modal-only (<SignInButton mode="modal">) — there are no dedicated /sign-in or /sign-up pages. User identity is bridged into a Drizzle-managed Postgres users table (lib/db/schema.ts) via three sync paths: on-demand resolveClerkUserToDbUser (lib/auth/get-current-user.ts), the client-triggered syncUser action (lib/actions/sync-user.ts + components/user-sync.tsx), and the Clerk webhook (app/api/clerk/webhook/route.ts), which also dual-writes to a Supabase "QCX-BACKEND" project via the sync_clerk_user RPC. Today the Clerk sync only reads emailAddresses, firstName, lastName, imageUrl — never externalAccounts. The users table has a generic unused metadata jsonb column but no Discord/external-account fields. No API route uses a static shared-secret/API-key check — the only inbound external caller (Clerk's webhook) uses svix HMAC verification, a good model for authenticating any bot→app calls. The app has a real containerized/always-on deployment path (Dockerfile, docker-compose, Cloud Run) in addition to Vercel, so a separate persistent bot service is feasible. Third-party API integrations follow a Composio pattern (mapbox_mcp/), settings live in a tabbed UI (components/settings/components/settings.tsx), and migrations are hand-written SQL with the next available number being 0009.


    🚀 Next Steps

    🤖 All AI agent prompts combined
    Task: 1
    
    Implement Phase 1: build the always-on, fully event-driven Discord bot that
    automatically DMs joiners a link to the dedicated app auth route. The bot
    performs join detection and DM messaging only — it does not mint tokens or link
    identities, and there is no slash-command/manual trigger.
    
    - Create a standalone bot (new top-level directory, e.g. `discord-bot/`, or a
    separate deployable service) using discord.js v14 with the privileged
    `GuildMembers` intent enabled; keep intents minimal (only what is needed for
    `guildMemberAdd` and role assignment).
    - Subscribe to the `guildMemberAdd` event; on every join, automatically DM the
    new member a static link to the dedicated app auth route
    (`${APP_BASE_URL}/auth/discord`) via `GuildMember#send()`.
    - Explicitly handle blocked/closed DMs as a graceful failure step: wrap
    `GuildMember#send()` in `.catch()`, log the failure, and do not crash the bot
    when a member has DMs disabled, has blocked the bot, or otherwise cannot receive
    the message (optionally fall back to a public welcome-channel mention if
    configured).
    - Deploy as a persistent process separate from Vercel — either a new service in
    `docker-compose.yaml` (alongside the existing app/db/qdrant services) or a
    Fly.io/Railway deployment. Document setup: create the Discord application + bot
    in the Discord Developer Portal, obtain the bot token, enable the Members
    intent, invite the bot to the guild with role-management permission.
    - Introduce env vars following the `SCREAMING_SNAKE_CASE`/provider-prefix
    convention: `DISCORD_BOT_TOKEN`, `DISCORD_GUILD_ID`, the app base URL used to
    build the auth link, and (only if the optional role-status check in Phase 3 is
    implemented) `DISCORD_BOT_API_SECRET`. Add the new secrets to
    `.env.local.example`.
    ===============================================================================
    
    Task: 2
    
    Implement Phase 2: configure Discord as a Clerk social connection that is hidden
    from the default sign-in/sign-up UI, and add the dedicated route that
    auto-triggers the `oauth_discord` flow plus the callback route that completes
    sign-in and lands the user at the configured post-auth destination. Discord must
    never appear on `/sign-in`, `/sign-up`, or the existing modal.
    
    - In the Clerk Dashboard → SSO connections, add the Discord connection; in
    production use custom credentials (create a Discord OAuth app in the Developer
    Portal, register Clerk's redirect URI, set Client ID/Secret in Clerk),
    requesting the `identify email` scopes plus `guilds`/`guilds.members.read` only
    if needed downstream. Disable "Enable for sign-up and sign-in" for the Discord
    connection so it is link-only and does NOT render in the default `<SignIn
    />`/`<SignUp />` components or the existing modal — Discord remains a fully
    configured Clerk connection but is invoked exclusively via the custom flow
    below. Document this configuration explicitly as a decision (dashboard steps,
    redirect URI, scopes, and the fact that Discord IS connected in Clerk but
    intentionally hidden from default surfaces) alongside the bot setup docs.
    - Create `app/auth/discord/page.tsx` as a client component (following the `'use
    client'` pattern of `app/offline/page.tsx`) that, on mount, calls Clerk's
    `signIn.authenticateWithRedirect({ strategy: 'oauth_discord', redirectUrl:
    '/sso-callback', redirectUrlComplete: '<post-auth destination>' })`. Explicitly
    define and document the post-auth redirect destination (`redirectUrlComplete`) —
    default to the app home/dashboard (`/`), configurable via env/constant. Document
    the extension point for carrying a per-join/verification token through the flow
    (query param on this route threaded into `redirectUrl` state for the callback to
    validate/consume) — the static-link default omits this but the route is
    structured to accept it additively. This route is the ONLY entry point for
    Discord auth; it is reachable only via the bot's DM link and is not linked from
    `/sign-in`, `/sign-up`, or the modal. It inherits the global `app/layout.tsx`
    (ClerkProvider) and requires no middleware matcher change; render a lightweight
    "Redirecting to Discord…" UI while the redirect is initiated.
    - Create `app/sso-callback/page.tsx` rendering Clerk's
    `<AuthenticateWithRedirectCallback />` to finish the OAuth return and establish
    the session, then land the user at the configured `redirectUrlComplete`
    destination. Document the correlation decision explicitly: the bot's
    `guildMemberAdd` Discord user ID and the authenticated Clerk user are correlated
    via the shared Discord `providerUserId` on `externalAccounts` (populated by the
    OAuth flow) — no separate join token is required for the default flow. Document
    the anti-abuse posture and the additive path: the static link only initiates a
    standard Discord OAuth consent, so replay/forwarding risk is low, but if a
    per-join token is later threaded through, this callback is where it would be
    validated/consumed before honoring `redirectUrlComplete`. Ensure this route
    inherits the global layout and requires no middleware change; new Clerk users
    created via Discord OAuth are handled natively by Clerk (no custom
    account-creation logic).
    ===============================================================================
    
    Task: 3
    
    Implement Phase 3: after a user authenticates via Discord OAuth, sync the
    Discord identity from Clerk's `externalAccounts` into the app DB, optionally
    mirror it to Supabase, drive guild role assignment from the bot, and optionally
    expose a settings view. There is no custom token/DB linking flow — identity is
    sourced authoritatively from Clerk.
    
    - Extend the Clerk sync paths to read the Discord `externalAccount` (where
    `provider === 'discord'`): update `resolveClerkUserToDbUser`/`syncUserProfile`
    in `lib/auth/get-current-user.ts` and the `user.created`/`user.updated` handler
    in `app/api/clerk/webhook/route.ts` (which today only read email/name/avatar) to
    also extract `providerUserId` (Discord ID), `username`, and `imageUrl` from
    `currentUser().externalAccounts` / `evt.data.external_accounts`. Persist the
    extracted Discord identity onto the `users` row; do NOT implement any custom
    "link Discord ID to an already-authenticated user" flow. Follow existing
    sync/action conventions (try/catch, `console.error`, best-effort profile
    refresh).
    - Update `lib/db/schema.ts`'s `users` table to add a `discordId` (`discord_id`)
    text column (unique) and a `discordUsername` column, and reuse/extend the
    existing `metadata jsonb` for variable Discord data (e.g. guild
    membership/roles). Add a hand-written migration
    `drizzle/migrations/0009_add_discord_account_link.sql` following existing
    conventions: descriptive header comment, `ADD COLUMN IF NOT EXISTS`, unique
    constraint / `CREATE INDEX IF NOT EXISTS idx_users_discord_id`, and, if RLS
    applies, mirror the established `is_clerk_user`/`clerk_id()` policy patterns.
    Values are written by the sync logic sourced from Clerk `externalAccounts`, not
    any bot-provided linking payload.
    - Decide whether the Discord link must also be mirrored to the Supabase
    "QCX-BACKEND": if so, extend the `sync_clerk_user` RPC signature/body (via the
    same or a follow-on migration) to accept and persist
    `discord_id`/`discord_username`, and pass the new values from the existing Clerk
    webhook sync path; otherwise explicitly scope Discord persistence to the primary
    Drizzle DB only and document that decision.
    - Have the bot assign configured role(s) to the guild member using its bot token
    and the Discord API (add-role on the `GuildMember`), keyed off the Discord user
    ID it already holds from `guildMemberAdd`, handling Discord rate limits and
    missing-permission cases gracefully. If role assignment must be gated on "the
    user has actually authenticated in the app," add a thin, read-only bot→app
    status endpoint under `app/api/discord/` (authenticated with the shared-secret
    `DISCORD_BOT_API_SECRET` header, mirroring the "handler enforces its own check"
    approach of `app/api/clerk/webhook/route.ts`) that answers whether a given
    Discord ID exists in the `users` table (sourced from the Clerk-synced
    `discord_id`). This bot↔app channel is for role/status only — never identity
    linking. Store configured role IDs and gating rules as bot env/config.
    - Optionally add a new "Linked Accounts / Integrations" tab in
    `components/settings/components/settings.tsx` using `Tabs.Trigger` +
    `Tabs.Content`, backed by a new component modeled on `user-management-form.tsx`,
    showing Discord link status, guild membership/roles, and optionally a disconnect
    affordance. Back it with a `noStore()` read action (modeled on
    `lib/actions/users.ts` `getSelectedModel`) returning the current user's Discord
    identity (from the Clerk-synced columns) plus any guild/role data. This tab is
    only for viewing an already-authenticated Discord link — initial authentication
    still happens exclusively via the bot-triggered `/auth/discord` route, never via
    a Discord button on the auth surfaces or modal.
    
    💡 Iterate on the plan with: @coderabbitai <feedback>
    Example Feedback
    - @coderabbitai You can skip phase 3. Add a simple unit test case for phase 2.
    - @coderabbitai For design choice 1 go ahead with option 3 and replan.
    

    💬 Have feedback or questions? Drop into our discord!

  3. ngoiyaeric commented on Jul 14, 2026

    @ngoiyaeric
    CollaboratorAuthor

    @coderabbitai I need the discord joiners to auth onto the app as well but not from the app with discord so not on the front end but from the backend on a different page.

  4. coderabbitai commented on Jul 14, 2026

    @coderabbitai
    Contributor
    🧩 Analysis chain

    🏁 Script executed:

    #!/bin/bash
    rg -ni 'discord' -g '!*.md' --type-not lock -C2 | head -200
    fd -i discord

    Length of output: 240


    🏁 Script executed:

    #!/bin/bash
    fd -i 'vercel.json|next.config' -a
    cat package.json 2>/dev/null | head -60
    fd -i -e yml -e yaml -p '.github/workflows'

    Length of output: 2393


    @ngoiyaeric, thanks for the clarification — that's a meaningfully different flow than Phase 1 of the earlier plan (which put Discord on the public /sign-in / /sign-up pages). Here's what I found and how I'd adjust:

    Key constraint discovered: This app is deployed as a Next.js app on Vercel (serverless — next.config.mjs, @vercel/analytics, @vercel/speed-insights). There's currently no Discord bot code, no discord.js dependency, and no always-on worker process in the repo. That matters because:

    • Detecting "someone joined the Discord server" requires a persistent Discord Gateway connection (guildMemberAdd event) — Vercel serverless functions can't hold that connection open.
    • A Discord slash command (HTTP Interactions) does work fine on Vercel, since Discord POSTs to your endpoint on-demand.

    So there are two ways to get "joiners" authenticated from the backend, on a page separate from the app's normal sign-in flow:

    1. Self-serve, Vercel-friendly (recommended default): A Discord bot registers a slash command (e.g. /verify) using the HTTP Interactions endpoint (a new app/api/discord/interactions/route.ts). When a server member runs it, the backend mints a short-lived one-time token, stores it, and DMs the user a link like /connect/discord?token=.... That dedicated page (not /sign-in, not /sign-up) validates the token server-side and then — instead of rendering Clerk's default <SignIn/> (which would also surface every other enabled social button) — calls useSignIn().authenticateWithRedirect({ strategy: 'oauth_discord', redirectUrl, redirectUrlComplete }) directly, so Discord OAuth only ever runs from this one backend-driven page. On callback, we mark discordId + a discordJoinSource: 'guild-verify' flag using the Phase 2 schema/sync logic already planned.

    2. True auto-detection on join (needs new infra): If you want authentication triggered the moment someone joins the guild (no /verify command needed), that requires a small always-on bot process (e.g. on Fly.io/Railway) listening for guildMemberAdd, which then calls back into a new authenticated API route on this app to mint the token and send the DM. This is more infrastructure to stand up and maintain outside Vercel.

    Before I turn this into a concrete ``, could you confirm:

    • Do you want option 1 (slash command, no new hosting) or option 2 (true join-triggered, needs an external bot host)?
    • Should this dedicated page/flow create brand-new app accounts for Discord joiners, or only be usable to link Discord to an existing account (e.g., someone who already signed up with email)?
    • Any specific route/path you'd like for this page (e.g. /connect/discord, /discord/verify)?

    Once you confirm, I'll produce the full `` covering the new interactions endpoint, token model/migration, the dedicated page, and the Clerk authenticateWithRedirect wiring — kept fully separate from the existing `/sign-in`/`/sign-up` pages and modal.


    ✏️ Learnings added
    Learnt from: ngoiyaeric
    URL: https://github.com/QueueLab/QCX/issues/737
    
    Timestamp: 2026-07-14T06:02:06.691Z
    Learning: In the QCX repo (Next.js App Router, deployed on Vercel per next.config.mjs and vercel/analytics/vercel/speed-insights dependencies), the user (ngoiyaeric) wants Discord server "joiners" to be able to authenticate into the app via a backend-driven flow on a dedicated page — explicitly NOT via a Discord OAuth button on the app's normal frontend sign-in/sign-up surfaces. Since Vercel serverless functions cannot maintain a persistent Discord Gateway connection, guild-join auto-detection would require an external always-on bot process; a Discord slash-command using HTTP Interactions is the Vercel-compatible alternative. No discord.js dependency or bot code exists in the repo as of this writing.
    

    Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


    💡 For code changes across multiple files, use @coderabbitai plan in an issue comment

  5. ngoiyaeric commented on Jul 14, 2026

    @ngoiyaeric
    CollaboratorAuthor

    @coderabbitai plan option 2 for an always on discord bot that prompt new joiners for their auth into a separate page on the app

  6. coderabbitai commented on Jul 14, 2026

    @coderabbitai
    Contributor

    I've updated the plan above based on your feedback. Ready to proceed when you are!

  7. ngoiyaeric commented on Jul 16, 2026

    @ngoiyaeric
    CollaboratorAuthor

    @coderabbitai Yes. This is a common pattern with Clerk.

    You can configure Discord as an authentication provider but keep it hidden from your public sign-in page. Users arriving from your Discord server (or Discord OAuth flow) can still authenticate via a dedicated route.

    Architecture

    Public Users
          │
          ▼
     /sign-in
     ├── Google
     ├── GitHub
     └── Email
          ▲
          │
     Discord users never see this page
    
    Discord Server
          │
          ▼
    Discord OAuth / Link
          │
          ▼
    /auth/discord
          │
          ▼
    Clerk Sign-In
          │
          ▼
    Discord OAuth
          │
          ▼
    Your App
    

    Option 1 (Recommended): Separate Discord Sign-In Route

    Instead of displaying Discord on your main Clerk component:

    /sign-in
    

    show only:

    • Google
    • GitHub
    • Email

    Then create

    /auth/discord
    

    where you immediately start the Discord OAuth flow.

    For example:

    import { useSignIn } from "@clerk/nextjs";
    
    export default function DiscordLogin() {
      const { signIn, isLoaded } = useSignIn();
    
      useEffect(() => {
        if (!isLoaded) return;
    
        signIn.authenticateWithRedirect({
          strategy: "oauth_discord",
          redirectUrl: "/sso-callback",
          redirectUrlComplete: "/dashboard",
        });
      }, [isLoaded]);
    
      return null;
    }

    Anyone visiting

    https://yourapp.com/auth/discord
    

    is immediately sent through Discord authentication.


    Option 2: Hide Discord from Clerk UI

    If you're using Clerk's <SignIn /> component, configure it so Discord isn't one of the displayed social providers.

    For example:

    <SignIn
      appearance={{
        elements: {
          socialButtonsBlockButton: "hidden",
        },
      }}
    />

    Or build a custom sign-in page that only renders the providers you want. Discord remains enabled in the Clerk Dashboard, but users won't see a Discord button on your main page.


    Discord Invite Flow

    Suppose someone joins your Discord community.

    Discord
       │
       ▼
    "Open QCX"
       │
       ▼
    https://app.queue.cx/auth/discord
       │
       ▼
    Discord OAuth
       │
       ▼
    Clerk Session
       │
       ▼
    Dashboard
    

    This gives Discord members a seamless entry into your application without exposing Discord login to everyone else.


    If you need to identify Discord members

    After Clerk authenticates them, you'll have access to their external account information, including the OAuth provider. You can check whether the session came from Discord:

    const user = await currentUser();
    
    const discordAccount = user.externalAccounts.find(
      account => account.provider === "oauth_discord"
    );

    You can then:

    • Grant access to Discord-only features.
    • Trigger onboarding for community members.
    • Sync Discord roles or server membership (using the Discord API if needed).

    For your QCX app, this approach keeps the main authentication experience clean while allowing a frictionless "Join Discord → Open App → Automatically Sign In" flow for community members. It also leaves room to add Discord-specific onboarding or permissions without affecting other sign-in methods.

  8. coderabbitai commented on Jul 16, 2026

    @coderabbitai
    Contributor

    I've updated the plan above based on your feedback. Ready to proceed when you are!

  9. coderabbitai commented on Jul 16, 2026

    @coderabbitai
    Contributor

    I've updated the plan above based on your feedback. Ready to proceed when you are!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions