OAuth 2.0 PKCE Code Verifier Mismatch on Mobile App Authentication Flow
Quick answer
A PKCE code verifier mismatch means the verifier sent during the token exchange doesn't match the code challenge originally sent during authorization — since...
A PKCE code verifier mismatch means the verifier sent during the token exchange doesn't match the code challenge originally sent during authorization — since these two values must be mathematically related and generated from the exact same original verifier, any break in that continuity (commonly, the app losing track of the original verifier between steps) produces this exact error.
The Problem
The final step of an OAuth PKCE flow, exchanging the authorization code for tokens, fails:
{
"error": "invalid_grant",
"error_description": "PKCE code verifier does not match the code challenge"
}
This might happen consistently, or only intermittently — particularly if the app was backgrounded, killed, or restarted by the OS between the authorization step and the token exchange step, which is a very common trigger on mobile specifically.
Why It Happens
PKCE (Proof Key for Code Exchange) requires the client to generate a random code_verifier, derive a code_challenge from it, send the challenge during authorization, and then send the original verifier during the later token exchange step — the authorization server checks that the verifier genuinely corresponds to the earlier challenge, confirming the same client that started the flow is the one completing it. Common causes of a mismatch:
- The app generates a code verifier, stores it only in memory, and the OS kills or backgrounds the app (very common on mobile, especially during the redirect to a system browser for authentication) — when the app resumes and receives the redirect callback, the in-memory verifier is gone, and if the code regenerates a new one at that point rather than retrieving the original, the mismatch is essentially guaranteed
- A race condition or bug causing a new verifier to be generated for the token exchange step, rather than correctly reusing the exact same verifier generated during the initial authorization step
- The verifier being stored in a location that doesn't reliably persist across the specific redirect/callback mechanism being used (a custom URL scheme, a universal link), losing the value somewhere in that specific handoff
The Fix
Persist the code verifier in durable, reliable storage (not just an in-memory variable) immediately after generating it, specifically so it survives the app being backgrounded or killed during the authorization redirect:
// Generate and immediately persist, not just hold in memory
const codeVerifier = generateCodeVerifier();
await SecureStore.setItemAsync('pkce_code_verifier', codeVerifier);
const codeChallenge = await generateCodeChallenge(codeVerifier);
// proceed with authorization request using codeChallenge
Using a secure, persistent storage mechanism (like Expo's SecureStore, or the platform-appropriate secure storage API) rather than a plain in-memory variable or even standard non-secure persistent storage is both a security best practice (the verifier is sensitive) and the specific fix for surviving app backgrounding/restart during the authorization redirect.
When handling the callback/redirect after authorization completes, explicitly retrieve the previously stored verifier rather than generating a new one at this point — this is the most common actual bug, where code mistakenly calls the verifier-generation function again during the callback handling instead of retrieving the originally stored value:
// In the callback/redirect handler
const storedVerifier = await SecureStore.getItemAsync('pkce_code_verifier');
const tokenResponse = await exchangeCodeForToken(authCode, storedVerifier);
// Clean up after successful exchange
await SecureStore.deleteItemAsync('pkce_code_verifier');
Clean up the stored verifier after a successful token exchange (or after a reasonable timeout for an abandoned flow), since it's no longer needed once the exchange completes, and leaving it around indefinitely is unnecessary given its single-use purpose within one specific authorization flow.
If you're using a well-established OAuth/PKCE library rather than implementing the flow manually, confirm it correctly handles this persistence concern for you automatically — most mature mobile OAuth libraries handle verifier storage and retrieval correctly out of the box, and implementing PKCE flow handling manually specifically increases the risk of introducing exactly this kind of persistence bug, making a well-tested library generally the safer choice unless you have a specific, clear reason to implement it yourself.
Still Not Working?
If you've confirmed persistence is correctly implemented and the same verifier is genuinely being retrieved and used consistently, check whether your app might be handling multiple simultaneous or overlapping authorization attempts — if a user triggers the login flow twice in quick succession (a double-tap on a login button, for example) before the first attempt completes, two different verifiers could be generated and stored using the same storage key, with the second overwriting the first, causing exactly this kind of mismatch for whichever flow's callback happens to complete using the now-overwritten value. Adding explicit debouncing or disabling the login trigger while a flow is already in progress prevents this specific race condition from occurring.