OAuth PKCE Flow Errors โ Fix code_challenge and code_verifier
Updated April 2026
PKCE errors are almost always caused by a code_verifier that does not match the code_challenge you sent in the authorization request. Here is how the flow works and where it typically breaks.
OAuth Error Response
{"error": "invalid_grant", "error_description": "PKCE verification failed"}How PKCE works
// Step 1 โ Generate a random verifier (before redirect)
function generateVerifier() { const array = new Uint8Array(32); crypto.getRandomValues(array); return base64URLEncode(array);
}
// Step 2 โ Generate challenge from verifier
async function generateChallenge(verifier) { const encoder = new TextEncoder(); const data = encoder.encode(verifier); const digest = await crypto.subtle.digest('SHA-256', data); return base64URLEncode(new Uint8Array(digest));
}
function base64URLEncode(array) { return btoa(String.fromCharCode(...array)) .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=/g, '');
}
Step 3 โ Authorization request with challenge
const verifier = generateVerifier();
sessionStorage.setItem('pkce_verifier', verifier); // STORE IT
const challenge = await generateChallenge(verifier);
const params = new URLSearchParams({ client_id: CLIENT_ID, redirect_uri: REDIRECT_URI, response_type: 'code', scope: 'openid profile', code_challenge: challenge, code_challenge_method: 'S256', state: generateState(),
});
window.location.href = `${AUTH_URL}?${params}`;
Step 4 โ Token exchange with verifier
// On callback page
const verifier = sessionStorage.getItem('pkce_verifier'); // RETRIEVE IT
const tokens = await fetch(TOKEN_URL, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', client_id: CLIENT_ID, redirect_uri: REDIRECT_URI, code: authorizationCode, code_verifier: verifier, // must match original }),
}).then(r => r.json());
Common bugs
| Bug | Symptom | Fix |
|---|---|---|
| Verifier not stored before redirect | sessionStorage empty on callback | Store before window.location.href |
| Wrong hash method | Challenge does not match | Always use SHA-256, not plain |
| Wrong base64 encoding | Challenge character mismatch | Use base64url (- and _ not + and /), no padding |
| Verifier regenerated on callback | New verifier does not match original challenge | Read from storage, do not regenerate |