Skip to content

auth.resend() uses implicit flow instead of PKCE flow, causing inconsistent confirmation flows #42527

@alexreardon

Description

@alexreardon

I ran into this bug while building out a new next.js, using supabase for authentication. The following bug report is generated by AI as it helped me find the bug, and it has a way better grip on the bug than I do!


Bug report

Description

When using supabase.auth.resend({ type: 'signup', email }) to resend a confirmation email, the confirmation link uses the implicit flow (tokens in URL hash fragment) instead of the PKCE flow (code in query parameters), even when the original signUp() call used PKCE.

This creates an inconsistency where:

  • Initial signup confirmation link: https://example.com/auth/confirm?token_hash=xxx&type=email (PKCE - works with server routes)
  • Resent confirmation link: https://example.com/auth/confirm#access_token=xxx&refresh_token=yyy (Implicit - requires client-side handling)

Root cause

The resend() endpoint explicitly forces implicit flow, as seen in the source code:

internal/api/resend.go#L119-L137

case mail.SignupVerification:
    if terr := models.NewAuditLogEntry(config.AuditLog, r, tx, user, models.UserConfirmationRequestedAction, "", nil); terr != nil {
        return terr
    }
    // PKCE not implemented yet
    return a.sendConfirmation(r, tx, user, models.ImplicitFlow)

The comment "PKCE not implemented yet" indicates this is a known limitation.

Expected behavior

supabase.auth.resend() should respect the flow type used in the original signup:

  • If the user signed up with PKCE parameters (code_challenge, code_challenge_method), resent confirmation emails should also use PKCE flow
  • The confirmation link format should be consistent between initial signup and resend

Actual behavior

  • supabase.auth.resend() always uses implicit flow
  • Confirmation links from resend contain tokens in the URL hash (#access_token=...)
  • Hash fragments are not sent to the server, so server-side route handlers (route.ts in Next.js) cannot process them

Impact

This forces developers to implement workarounds:

  1. Cannot use server route handlers for /auth/confirm because hash fragments are client-only
  2. Must use a client component with useEffect to read window.location.hash
  3. Must implement dual flow handling to support both PKCE (initial signup) and implicit (resend) flows
  4. Creates inconsistent UX where the same feature works differently depending on whether it's the first or second confirmation email

Reproduction

// 1. Sign up with PKCE flow
const { data, error } = await supabase.auth.signUp({
  email: 'user@example.com',
  password: 'password',
  options: {
    emailRedirectTo: 'https://example.com/auth/confirm'
  }
})
// Result: Email contains link with ?token_hash=xxx (PKCE)

// 2. Resend confirmation email
const { error: resendError } = await supabase.auth.resend({
  type: 'signup',
  email: 'user@example.com',
  options: {
    emailRedirectTo: 'https://example.com/auth/confirm'
  }
})
// Result: Email contains link with #access_token=xxx (Implicit)

Workaround

Requires separating standard confirmation (PKCE) from resend confirmation (implicit flow).

Approach 1: Separate routes (recommended for server-first frameworks)

Create a dedicated page for resend confirmations that extracts tokens from the hash and sends them to a server action:

// app/auth/confirm-resend/page.tsx
'use client'

import { useEffect, useState } from 'react'
import { confirmResendAction } from '@/actions/confirm-resend'

export default function ConfirmResendPage() {
  const [status, setStatus] = useState<'processing' | 'error'>('processing')

  useEffect(() => {
    async function handleImplicitFlow() {
      const hash = window.location.hash
      if (!hash) {
        setStatus('error')
        return
      }

      // Extract tokens from hash fragment (client-side only)
      const params = new URLSearchParams(hash.slice(1))
      const accessToken = params.get('access_token')
      const refreshToken = params.get('refresh_token')

      if (!accessToken || !refreshToken) {
        setStatus('error')
        return
      }

      // Send to server action to establish session
      const result = await confirmResendAction(accessToken, refreshToken)

      if (result.success) {
        window.location.replace('/')
      } else {
        setStatus('error')
      }
    }

    handleImplicitFlow()
  }, [])

  return <div>{status === 'processing' ? 'Confirming...' : 'Error'}</div>
}
// actions/confirm-resend.ts
'use server'

import { getSupabase } from '@/server/supabase'

export async function confirmResendAction(
  accessToken: string,
  refreshToken: string
): Promise<{ success: boolean }> {
  const supabase = await getSupabase()

  const { error } = await supabase.auth.setSession({
    access_token: accessToken,
    refresh_token: refreshToken,
  })

  return { success: !error }
}

Then update the resend action to redirect to the separate page:

// In your resend confirmation action
await supabase.auth.resend({
  type: 'signup',
  email,
  options: {
    emailRedirectTo: 'https://example.com/auth/confirm-resend'  // Different route
  }
})

Approach 2: Hybrid single-page handler

Handle both flows in one page (less clean but avoids separate routes):

'use client'

import { useEffect } from 'react'
import { confirmCodeAction } from '@/actions/confirm-code'
import { confirmResendAction } from '@/actions/confirm-resend'

export default function ConfirmPage() {
  useEffect(() => {
    async function confirm() {
      // Check for PKCE flow (query param)
      const params = new URLSearchParams(window.location.search)
      const code = params.get('code')

      if (code) {
        // Send code to server action for PKCE flow
        const result = await confirmCodeAction(code)
        if (result.type === 'success') {
          window.location.replace('/')
        } else {
          window.location.replace('/auth/confirm/failure')
        }
        return
      }

      // Check for implicit flow (hash fragment)
      const hashParams = new URLSearchParams(window.location.hash.substring(1))
      const accessToken = hashParams.get('access_token')
      const refreshToken = hashParams.get('refresh_token')

      if (accessToken && refreshToken) {
        const result = await confirmResendAction(accessToken, refreshToken)
        if (result.type === 'success') {
          window.location.replace('/')
        } else {
          window.location.replace('/auth/confirm/failure')
        }
        return
      }

      window.location.replace('/auth/confirm/failure')
    }

    confirm()
  }, [])

  return <div>Confirming...</div>
}
// actions/confirm-code.ts
'use server'

import { getSupabase } from '@/server/supabase'

type ConfirmCodeResult = { type: 'success' } | { type: 'error'; message: string }

export async function confirmCodeAction(code: string): Promise<ConfirmCodeResult> {
  const supabase = await getSupabase()
  const { error } = await supabase.auth.exchangeCodeForSession(code)

  if (error) {
    return { type: 'error', message: 'Invalid or expired link' }
  }

  const { data: userData, error: userError } = await supabase.auth.getUser()
  if (userError || !userData.user?.email_confirmed_at) {
    return { type: 'error', message: 'Email not confirmed' }
  }

  return { type: 'success' }
}

Proposed solution

  1. Update resend() endpoint to detect and preserve the original flow type
  2. Store the flow type preference (PKCE vs implicit) when user signs up
  3. Use the same flow for resend confirmation emails
  4. Alternatively, add an option to resend() to specify the flow type:
await supabase.auth.resend({
  type: 'signup',
  email: 'user@example.com',
  options: {
    emailRedirectTo: 'https://example.com/auth/confirm',
    codeChallengeMethod: 's256',  // Enable PKCE for resend
    codeChallenge: challenge
  }
})

Environment

  • Supabase Auth version: Latest (as of February 2026)
  • Framework: Next.js 16.1.6 (App Router)
  • Flow: Server-side authentication with PKCE

Additional context

This same issue likely affects:

  • Password recovery resend (type: 'recovery')
  • Email change confirmation resend (type: 'email_change')
  • Any other resend operations that currently hardcode implicit flow

References

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions