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')
signInAnonymously()
updateUser({ email }, { emailRedirectTo }) — verifier is written to storage, confirmation email sent
- 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)
- Storage no longer contains
${storageKey}-code-verifier
- Open the emailed link;
GET /auth/v1/verify succeeds (303, user_modified — the email change IS applied server-side)
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.
Bug description
GoTrueClient._saveSession()unconditionally deletes the pending PKCE code verifier as its first action:_saveSessionruns 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 withAuthPKCECodeVerifierMissingErroreven though the emailed link itself was perfectly valid (andGET /verifysucceeded server-side).The most common victim: anonymous sign-in →
updateUser({ email })(orsignInWithOtp) to convert/link the account. WithautoRefreshToken: true(the default) — or any app code that callsrefreshSession()while showing a "check your email" screen — the failure is deterministic. The server applies the email change at/verifytime, 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
_saveSessionhad 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_saveSessiondeletion 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')
signInAnonymously()updateUser({ email }, { emailRedirectTo })— verifier is written to storage, confirmation email sentrefreshSession()once (our app polled it; server logs show a token rotation seconds after step 2)${storageKey}-code-verifierGET /auth/v1/verifysucceeds (303,user_modified— the email change IS applied server-side)exchangeCodeForSession(code)→ throwsAuthPKCECodeVerifierMissingErrorlocally (no network request is even made)Observed in production with
@supabase/supabase-js2.108.2 on React Native (Hermes, Expo SDK 56); mechanism verified by reading the installed source and correlating with GoTrue server logs (verify 303 + zerogrant_type=pkcerequests).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_exchangeCodeForSessionalready handles cleanup). Orphaned-verifier cleanup could instead happen at new-flow start (eachgetCodeChallengeAndMethodcall already overwrites) or on sign-out (_removeSessionalready removes it).Workaround (what we shipped)
Never call
refreshSession()(or anything that saves a session) while a PKCE email flow is pending — we replaced arefreshSession()-based polling loop with plaingetUser()reads and refresh exactly once after the conversion is confirmed. Works, but every integrator has to discover this footgun the hard way.