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:
- Cannot use server route handlers for
/auth/confirm because hash fragments are client-only
- Must use a client component with
useEffect to read window.location.hash
- Must implement dual flow handling to support both PKCE (initial signup) and implicit (resend) flows
- 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
- Update
resend() endpoint to detect and preserve the original flow type
- Store the flow type preference (PKCE vs implicit) when user signs up
- Use the same flow for resend confirmation emails
- 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
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 originalsignUp()call used PKCE.This creates an inconsistency where:
https://example.com/auth/confirm?token_hash=xxx&type=email(PKCE - works with server routes)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
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:code_challenge,code_challenge_method), resent confirmation emails should also use PKCE flowActual behavior
supabase.auth.resend()always uses implicit flow#access_token=...)route.tsin Next.js) cannot process themImpact
This forces developers to implement workarounds:
/auth/confirmbecause hash fragments are client-onlyuseEffectto readwindow.location.hashReproduction
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:
Then update the resend action to redirect to the separate page:
Approach 2: Hybrid single-page handler
Handle both flows in one page (less clean but avoids separate routes):
Proposed solution
resend()endpoint to detect and preserve the original flow typeresend()to specify the flow type:Environment
Additional context
This same issue likely affects:
type: 'recovery')type: 'email_change')References