How to Fix OAuth 2.0 PKCE Flow Failing With "invalid_code_verifier" on Mobile App
Quick answer
Your mobile app's OAuth login flow reaches the authorization server, the user approves, but the final token exchange fails with an "invalid_code_verifier"...
Your mobile app's OAuth login flow reaches the authorization server, the user approves, but the final token exchange fails with an "invalid_code_verifier" error. PKCE (Proof Key for Code Exchange) requires the exact same verifier value to be used at both the start and end of the flow, and this error means the value your app sent during token exchange doesn't match what it originally generated and sent as the challenge.
The Problem
The user completes login in the browser or webview, gets redirected back to the app, but the token exchange fails:
POST /oauth/token HTTP/1.1
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&code=abc123&code_verifier=xyz789&client_id=myapp
HTTP/1.1 400 Bad Request
{"error": "invalid_grant", "error_description": "invalid_code_verifier"}
Why It Happens
PKCE works by having the client generate a random secret (the "verifier"), derive a "challenge" from it (typically a SHA-256 hash, base64url-encoded), send only the challenge during the initial authorization request, and then send the original verifier during the final token exchange β the server checks that hashing the verifier produces the challenge it received earlier, proving the token exchange is coming from the same client that started the flow. This error means that check failed. Common causes on mobile specifically:
- The verifier isn't persisted correctly across the app's lifecycle β the user's browser/webview session for login can outlive the in-memory state of your app (especially if the app is backgrounded or the OS reclaims memory during the external browser flow), and if the verifier was only held in a variable rather than genuinely persisted, it's gone by the time the redirect returns.
- A new verifier is accidentally generated on each app launch or deep-link handling, rather than reusing the one generated at the very start of this specific flow.
- The challenge method mismatch β sending a plain-text challenge (
code_challenge_method=plain) while the server expects SHA-256 (S256), or vice versa, causing the server's verification hash comparison to fail even with a correctly persisted verifier. - Incorrect base64url encoding of the challenge or verifier β using standard base64 (with
+,/, and padding) instead of the URL-safe variant PKCE specifically requires, causing a subtly different string than what the server expects.
The Fix
Persist the verifier reliably across the entire flow, using secure device storage rather than an in-memory variable that could be lost if the app is backgrounded or restarted during the external browser step:
// Generate and store BEFORE redirecting to the authorization URL
import * as SecureStore from 'expo-secure-store';
const verifier = generateRandomString(64);
await SecureStore.setItemAsync('pkce_verifier', verifier);
const challenge = base64urlEncode(await sha256(verifier));
const authUrl = `https://auth.example.com/authorize?response_type=code&client_id=myapp&code_challenge=${challenge}&code_challenge_method=S256&redirect_uri=myapp://callback`;
// Navigate the user to authUrl
When the app receives the redirect callback, retrieve the exact same stored verifier rather than generating a new one:
// On the redirect/deep-link handler
const verifier = await SecureStore.getItemAsync('pkce_verifier');
const response = await fetch('https://auth.example.com/oauth/token', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({
grant_type: 'authorization_code',
code: authorizationCode,
code_verifier: verifier,
client_id: 'myapp',
}),
});
await SecureStore.deleteItemAsync('pkce_verifier'); // clean up after use
Make sure the challenge method used when generating the challenge matches exactly what you specify in code_challenge_method, and confirm your hashing and encoding implementation is genuinely producing correct base64url output (no +, /, or trailing = padding characters):
function base64urlEncode(buffer) {
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
Still Not Working?
If persistence and encoding both look correct but the error still occurs intermittently, check whether your app might be handling the redirect callback more than once β some deep-linking configurations can trigger the callback handler twice for a single actual redirect, and if the second invocation tries to exchange an already-used authorization code (or retrieves a verifier that was already cleaned up after the first successful exchange), it produces exactly this kind of confusing, hard-to-reproduce failure:
let isExchanging = false;
async function handleCallback(code) {
if (isExchanging) return; // guard against duplicate invocation
isExchanging = true;
try {
await exchangeCodeForToken(code);
} finally {
isExchanging = false;
}
}
Adding this kind of guard against duplicate handling is a common, practical fix for mobile deep-link edge cases that don't always show up consistently in testing but appear intermittently in real-world usage.