Introduction

PKCE (Proof Key for Code Exchange) adds a code_challenge to the authorization request and a code_verifier to the token exchange. If the code_verifier sent during token exchange does not match the code_challenge from the authorization step, the server rejects the request with invalid_grant. This can happen due to state management bugs, intercepted flows, or incorrect S256 transformation.

Symptoms

  • Token exchange returns:
  • `
  • HTTP/1.1 400 Bad Request
  • {
  • "error": "invalid_grant",
  • "error_description": "PKCE code verifier does not match code challenge"
  • }
  • `
  • Or:
  • `
  • {
  • "error": "invalid_request",
  • "error_description": "code_verifier is required when code_challenge was sent"
  • }
  • `
  • Works in browser-based flow but fails in native/mobile app
  • Intermittent failures when multiple authorization requests overlap

Common Causes

  • code_verifier generated for first request but second request's code used
  • S256 transformation (SHA256 + base64url) implemented incorrectly
  • code_verifier lost between redirect and callback (not persisted)
  • Multiple concurrent login attempts with different verifiers
  • Auth server requires PKCE but client sends plain instead of S256

Step-by-Step Fix

  1. 1.Verify S256 code_challenge generation:
  2. 2.```bash
  3. 3.# Generate a valid code_verifier and code_challenge
  4. 4.# Step 1: Generate random code_verifier (43-128 chars)
  5. 5.CODE_VERIFIER=$(python3 -c "
  6. 6.import secrets, base64
  7. 7.verifier = secrets.token_urlsafe(64)
  8. 8.print(verifier[:86]) # Truncate to max 128 chars
  9. 9.")
base64tr '+/' '-_'

echo "code_verifier: $CODE_VERIFIER" echo "code_challenge: $CODE_CHALLENGE" ```

  1. 1.Trace the full PKCE flow:
  2. 2.```bash
  3. 3.# Step 1: Authorization request (must include code_challenge)
  4. 4.curl -v "https://auth.example.com/authorize?response_type=code&client_id=myapp&redirect_uri=https://myapp.example.com/callback&scope=openid&state=random123&code_challenge=$CODE_CHALLENGE&code_challenge_method=S256"

# Step 2: After redirect with authorization code, exchange token curl -v -X POST https://auth.example.com/token \ -d "grant_type=authorization_code" \ -d "code=AUTH_CODE_FROM_REDIRECT" \ -d "redirect_uri=https://myapp.example.com/callback" \ -d "client_id=myapp" \ -d "code_verifier=$CODE_VERIFIER" ```

  1. 1.Debug in a SPA/React application:
  2. 2.```javascript
  3. 3.// WRONG - code_verifier stored in component state (lost on redirect)
  4. 4.function LoginButton() {
  5. 5.const [codeVerifier, setCodeVerifier] = useState(generateVerifier());
  6. 6.// After redirect, this component remounts with a NEW verifier
  7. 7.}

// CORRECT - persist code_verifier in sessionStorage function initiatePKCE() { const codeVerifier = generateRandomVerifier(); const codeChallenge = btoa(sha256(codeVerifier)) .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');

sessionStorage.setItem('pkce_code_verifier', codeVerifier); sessionStorage.setItem('pkce_state', generateRandomState());

const authUrl = https://auth.example.com/authorize? + response_type=code&client_id=myapp& + redirect_uri=${encodeURIComponent(window.location.origin + '/callback')}& + scope=openid profile&state=${sessionStorage.getItem('pkce_state')}& + code_challenge=${codeChallenge}&code_challenge_method=S256;

window.location.href = authUrl; }

// In the callback component: function handleCallback() { const codeVerifier = sessionStorage.getItem('pkce_code_verifier'); if (!codeVerifier) { throw new Error('PKCE code_verifier lost - possible intercepted flow'); } // Use codeVerifier for token exchange sessionStorage.removeItem('pkce_code_verifier'); sessionStorage.removeItem('pkce_state'); } ```

  1. 1.For mobile apps using AppAuth:
  2. 2.```kotlin
  3. 3.// Android with AppAuth library (handles PKCE automatically)
  4. 4.val authService = AuthorizationService(this)
  5. 5.val builder = AuthorizationRequest.Builder(
  6. 6.serviceConfiguration,
  7. 7.CLIENT_ID,
  8. 8.ResponseTypeValues.CODE,
  9. 9.Uri.parse(REDIRECT_URI)
  10. 10.).setScopes("openid", "profile", "email")
  11. 11..setCodeVerifier() // AppAuth generates code_verifier/challenge automatically

val authIntent = authService.getAuthorizationRequestIntent(builder.build()) startActivityForResult(authIntent, RC_AUTH) ```

Prevention

  • Use established OIDC libraries (oidc-client-ts, AppAuth, passport-oauth2) that handle PKCE
  • Persist code_verifier in sessionStorage, not component state
  • Generate a new code_verifier for each authorization request
  • Validate state parameter matches before exchanging the code
  • Log the code_challenge_method used (always S256, never plain)
  • Use a single active PKCE session - reject overlapping authorization requests