ASK KNOX
beta
LESSON 255

Identity Linking — One User, Multiple Login Methods

Discord creates user A. Email creates user B. Same person, two accounts. Supabase's identity linking solves this in 15 lines. The custom implementation we replaced was 80 lines. Here's the state machine.

6 min read

Every auth system eventually encounters the same user identity problem.

A user signs up with their email address and password. Three months later, they discover the "Login with Discord" button and use it, because they prefer OAuth to remembering passwords. Their Discord account's email happens to match their existing account email.

Are these the same user? Obviously yes. Does your auth system know that? Maybe not.

Without identity linking, the result is two separate accounts — one email-based, one Discord-based — with no data shared between them. The user's settings, history, and entitlements are split across two records. Every time they use a different login method, they see a different state of their account.

The Identity Data Model

Supabase's auth model separates users from identities:

auth.users table:
  id: uuid (primary key)
  email: string
  created_at: timestamp

auth.identities table:
  id: uuid
  user_id: uuid (foreign key → users.id)
  provider: string  ('email', 'discord', 'google', etc.)
  provider_id: string  (the provider's user ID for this account)
  identity_data: jsonb  (provider-specific data: avatar, username, etc.)

One user can have multiple identities. One identity belongs to exactly one user. The user_id is the stable identifier — it never changes, regardless of how many login methods are added or removed.

This model enables:

  • A user with both email/password AND Discord login
  • A user with Discord AND Google AND GitHub
  • A user who removes Discord but keeps email (as long as email is not the only method)

The Identity State Machine

[No Account]
    ↓ signUp(email, password)
[Email Only]
    ↓ linkIdentity({ provider: 'discord' })
[Email + Discord]
    ↓ unlinkIdentity(discord_identity)
[Email Only]

[No Account]
    ↓ signInWithOAuth({ provider: 'discord' })
[Discord Only]
    ↓ linkIdentity({ provider: 'google' })
[Discord + Google]

[Discord Only]
    ↓ unlinkIdentity(discord_identity)  ← BLOCKED: would lock out user
[Discord Only]  (no change — error returned)

Implementing Identity Linking

The linkIdentity method works like signInWithOAuth — it initiates an OAuth redirect flow. The difference is that when the OAuth completes, instead of creating or finding a session, Supabase adds the new provider as an identity on the current user.

// "Connect Discord" button handler
async function connectDiscord() {
  const { error } = await supabase.auth.linkIdentity({
    provider: 'discord',
    options: {
      redirectTo: `${window.location.origin}/auth/callback?next=/settings`,
    }
  })

  if (error) {
    console.error('Failed to initiate Discord link:', error.message)
    toast.error('Could not connect Discord account')
  }
  // If successful, user is redirected to Discord — no further client code needed
}

The callback route handles both regular sign-in and identity linking with the same exchangeCodeForSession call. Supabase detects whether the OAuth completion is a new auth or an identity link from the flow state.

Lockout Prevention

Before allowing a user to disconnect an identity, check that it is not their last one:

async function disconnectDiscord(userId: string) {
  const { data: { user } } = await supabase.auth.getUser()

  if (!user) {
    throw new Error('Not authenticated')
  }

  const identities = user.identities ?? []

  if (identities.length <= 1) {
    throw new Error(
      'Cannot remove your only login method. ' +
      'Add another login method first, then disconnect Discord.'
    )
  }

  const discordIdentity = identities.find(i => i.provider === 'discord')

  if (!discordIdentity) {
    throw new Error('Discord account is not connected')
  }

  const { error } = await supabase.auth.unlinkIdentity(discordIdentity)

  if (error) {
    throw new Error(`Failed to disconnect Discord: ${error.message}`)
  }
}

Always check identities.length <= 1 before calling unlinkIdentity. If you have custom logic that only counts "active" identities, the check must use the same definition of "active" — a disabled email still counts as a login method for lockout purposes.

Auto-Linking by Email

Supabase can automatically link identities when the email matches. This requires enabling "Automatic provider linking" in the Supabase dashboard under Authentication → Sign In / Up.

With auto-linking enabled:

  1. User has an email/password account
  2. User clicks "Login with Discord"
  3. Discord OAuth completes with the same email
  4. Supabase detects the email match
  5. Discord identity is automatically added to the existing email account
  6. User is logged in to the original account (not a new one)

Without auto-linking, step 6 would create a new, separate Discord-only account.

Case Study: 80 Lines to 15 Lines

Before Supabase's linkIdentity API was used, the jeremyknox.ai Academy had a custom Discord integration:

// The old approach: ~80 lines
async function handleDiscordConnect(userId: string, discordCode: string) {
  // 1. Exchange Discord code manually
  const tokenResponse = await fetch('https://discord.com/api/oauth2/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      client_id: process.env.DISCORD_CLIENT_ID!,
      client_secret: process.env.DISCORD_CLIENT_SECRET!,
      code: discordCode,
      grant_type: 'authorization_code',
      redirect_uri: process.env.DISCORD_REDIRECT_URI!,
    })
  })
  const { access_token } = await tokenResponse.json()

  // 2. Fetch Discord user info
  const userResponse = await fetch('https://discord.com/api/users/@me', {
    headers: { Authorization: `Bearer ${access_token}` }
  })
  const discordUser = await userResponse.json()

  // 3. Update custom columns in the users table
  const { error } = await supabase
    .from('profiles')
    .update({
      discord_id: discordUser.id,
      discord_username: discordUser.username,
      discord_avatar: discordUser.avatar,
    })
    .eq('id', userId)

  if (error) throw error
}

This approach required managing the Discord OAuth flow manually, maintaining separate columns for Discord data, and writing separate logic for Discord vs non-Discord users everywhere in the app.

The replacement using linkIdentity:

// The new approach: ~15 lines
async function connectDiscord() {
  const { data: { user } } = await supabase.auth.getUser()
  if (!user) throw new Error('Not authenticated')

  const { error } = await supabase.auth.linkIdentity({
    provider: 'discord',
    options: { redirectTo: `${window.location.origin}/auth/callback` }
  })

  if (error) throw error
}

Supabase handles the OAuth exchange, stores the Discord identity data in auth.identities, and the Discord user info is available via user.identities.find(i => i.provider === 'discord')?.identity_data. The custom columns were deleted.

Lesson 7 Drill

In any application you maintain with multiple login methods, answer:

  1. Can a user have both email/password and a social login? Is this handled explicitly?
  2. What happens if a user tries to sign up with Google using an email that already exists as an email/password account?
  3. Is there a "Disconnect [Provider]" feature? Does it check for lockout before proceeding?
  4. Where is the provider-specific user data (Discord username, Google avatar, etc.) stored?

If your answers reveal either duplicate account creation or missing lockout protection, you have action items.