Short answer
Exchange the code once, in the same browser that started the sign-in, within 5 minutes. In Next.js and other SSR apps, create the client with @supabase/ssr so the code verifier is stored in a cookie the callback route can read. For email links that users open on another device, use a token_hash link with verifyOtp instead of a PKCE code.
Why the PKCE exchange fails, and the fix
01
The link was opened in a different browser or device
“That means the code exchange must be initiated on the same browser and device where the flow was started.” Supabase, PKCE flow (Limitations)
The code verifier is saved in the browser that started the flow. An email link opened in the mail app’s browser, or on a phone, has the code but no verifier. For email confirmations and password resets, build the link with the token hash and call verifyOtp on your confirm route; it doesn’t need the verifier. Use type recovery for password reset links. Also check that the redirect URL is in the Auth redirect allow-list, with the same www or non-www host, so the callback runs on the origin that holds the cookie.
// email template link: {{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email const { error } = await supabase.auth.verifyOtp({ token_hash, type: 'email' });02
The verifier is in localStorage, the exchange runs on the server
“The code verifier is created and stored locally when the Auth flow is first initiated.” Supabase, PKCE flow (Limitations)
A browser client made with createClient from supabase-js keeps the verifier in localStorage, and a server has no localStorage. A route handler that exchanges the code finds no verifier and sends an empty one. Use createBrowserClient and createServerClient from @supabase/ssr, which keep it in cookies.
// app/auth/callback/route.ts const code = new URL(request.url).searchParams.get('code'); if (code) { const supabase = await createClient(); // createServerClient from @supabase/ssr const { error } = await supabase.auth.exchangeCodeForSession(code); }03
The code was used twice or expired
“PKCE flow state to which the API request relates no longer exists. Flow states expire after a while and are progressively cleaned up, which can cause this error.” Supabase, Error codes (flow_state_not_found)
React strict mode, a double render or the user pressing back can run the exchange a second time, and the first run already consumed the flow state. In a browser app, the client already exchanges the code on load (detectSessionInUrl is on by default), so a manual exchangeCodeForSession on the same URL is a second exchange: remove one. Exchange the code in one place only, and send the user to sign in again if it is older than 5 minutes.
04
Two sign-ins started in the same browser
“the code verifier stored for the earlier flow is overwritten by the later one, and exchanging the first flow's code fails.” Supabase, PKCE flow (Overlapping flows)
Starting OAuth in two tabs overwrites the first verifier, so the first tab’s code fails to exchange (usually bad_code_verifier or flow_state_not_found). Let only one sign-in run at a time, or disable the button after the first click.
Checked against the vendor’s docs on : Supabase docs: PKCE flow; Supabase docs: Auth error codes; Supabase docs: Creating a Supabase client for SSR.
Rather hand it over?
Fixed scope, fixed price, paid upfront. The work happens in writing; no call needed.
- from $450App Fix TicketOne reproducible bug in an existing mobile or web app, diagnosed and fixed.See scope and start
- from $450AI-Built App RescueA Lovable, Bolt or Cursor app checked end to end in 3 days, then its worst problems fixed at a fixed price.No issues worth fixing, no charge. If the Launch Check finds nothing rated medium or higher, Simon refunds the full $450. You keep the report either way. This applies to the first 10 Launch Checks bought after October 30, 2026.See scope and start
Related: Supabase email link invalid or expired · Supabase “Auth session missing” · Supabase auth email links point to localhost · Supabase JWSError JWSInvalidSignature