Skip to content

_saveSession() deletes the pending PKCE code verifier on every token refresh — breaks PKCE email flows started while a session exists (e.g. anonymous→permanent conversion) #2512

Description

@moses4wen

Bug description

GoTrueClient._saveSession() unconditionally deletes the pending PKCE code verifier as its first action:

// packages/core/auth-js/src/GoTrueClient.ts (current master, ~line 5111)
private async _saveSession(session: Session) {
  await removeItemAsync(this.storage, `${this.storageKey}-code-verifier`)
  ...

_saveSession runs on every session save — including every background refresh-token rotation (_callRefreshToken_saveSession). So any PKCE email flow started while a session already exists has its code verifier silently destroyed by the next token refresh that happens between sending the email and the user clicking the link. exchangeCodeForSession() then fails client-side with AuthPKCECodeVerifierMissingError even though the emailed link itself was perfectly valid (and GET /verify succeeded server-side).

The most common victim: anonymous sign-in → updateUser({ email }) (or signInWithOtp) to convert/link the account. With autoRefreshToken: true (the default) — or any app code that calls refreshSession() while showing a "check your email" screen — the failure is deterministic. The server applies the email change at /verify time, but the client can never complete the code exchange, so the user gets stuck in a "link expired or already used" loop while their account was in fact converted.

Where it came from

Introduced by #1759 ("fix(auth): code verifier remains in storage during edge cases", released in v2.86.1, 2025-12-03). That PR's goal was cleaning up orphaned verifiers; the description itself notes uncertainty about the placement ("I wasn't sure where else to handle this"). Placing the cleanup in _saveSession had the unintended side effect above, because token refreshes also save sessions. Still present in latest (2.110.2 as of 2026-07-10).

Note: the verifier is already correctly removed in _exchangeCodeForSession's own success/failure paths, so the _saveSession deletion is redundant for the flow it was meant to clean up — it only adds the refresh-path destruction.

Reproduction (React Native / Expo, supabase-js ≥ 2.86.1, flowType 'pkce')

  1. signInAnonymously()
  2. updateUser({ email }, { emailRedirectTo }) — verifier is written to storage, confirmation email sent
  3. Cause one session save before clicking the link — either wait for auto-refresh, or call refreshSession() once (our app polled it; server logs show a token rotation seconds after step 2)
  4. Storage no longer contains ${storageKey}-code-verifier
  5. Open the emailed link; GET /auth/v1/verify succeeds (303, user_modified — the email change IS applied server-side)
  6. exchangeCodeForSession(code) → throws AuthPKCECodeVerifierMissingError locally (no network request is even made)

Observed in production with @supabase/supabase-js 2.108.2 on React Native (Hermes, Expo SDK 56); mechanism verified by reading the installed source and correlating with GoTrue server logs (verify 303 + zero grant_type=pkce requests).

Suggested fix

Remove the unconditional delete from _saveSession, or gate it so it only runs when the save originates from a completed PKCE exchange (where _exchangeCodeForSession already handles cleanup). Orphaned-verifier cleanup could instead happen at new-flow start (each getCodeChallengeAndMethod call already overwrites) or on sign-out (_removeSession already removes it).

Workaround (what we shipped)

Never call refreshSession() (or anything that saves a session) while a PKCE email flow is pending — we replaced a refreshSession()-based polling loop with plain getUser() reads and refresh exactly once after the conversion is confirmed. Works, but every integrator has to discover this footgun the hard way.

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