OAuth

OAuth PKCE Flow Errors — Fix code_challenge and code_verifier

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

BugSymptomFix
Verifier not stored before redirectsessionStorage empty on callbackStore before window.location.href
Wrong hash methodChallenge does not matchAlways use SHA-256, not plain
Wrong base64 encodingChallenge character mismatchUse base64url (- and _ not + and /), no padding
Verifier regenerated on callbackNew verifier does not match original challengeRead from storage, do not regenerate
Debug your OAuth error live → OAuthFixer