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_verifiergenerated for first request but second request'scodeused- S256 transformation (SHA256 + base64url) implemented incorrectly
code_verifierlost between redirect and callback (not persisted)- Multiple concurrent login attempts with different verifiers
- Auth server requires PKCE but client sends
plaininstead ofS256
Step-by-Step Fix
- 1.Verify S256 code_challenge generation:
- 2.```bash
- 3.# Generate a valid code_verifier and code_challenge
- 4.# Step 1: Generate random code_verifier (43-128 chars)
- 5.CODE_VERIFIER=$(python3 -c "
- 6.import secrets, base64
- 7.verifier = secrets.token_urlsafe(64)
- 8.print(verifier[:86]) # Truncate to max 128 chars
- 9.")
| base64 | tr '+/' '-_' |
echo "code_verifier: $CODE_VERIFIER" echo "code_challenge: $CODE_CHALLENGE" ```
- 1.Trace the full PKCE flow:
- 2.```bash
- 3.# Step 1: Authorization request (must include code_challenge)
- 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.Debug in a SPA/React application:
- 2.```javascript
- 3.// WRONG - code_verifier stored in component state (lost on redirect)
- 4.function LoginButton() {
- 5.const [codeVerifier, setCodeVerifier] = useState(generateVerifier());
- 6.// After redirect, this component remounts with a NEW verifier
- 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.For mobile apps using AppAuth:
- 2.```kotlin
- 3.// Android with AppAuth library (handles PKCE automatically)
- 4.val authService = AuthorizationService(this)
- 5.val builder = AuthorizationRequest.Builder(
- 6.serviceConfiguration,
- 7.CLIENT_ID,
- 8.ResponseTypeValues.CODE,
- 9.Uri.parse(REDIRECT_URI)
- 10.).setScopes("openid", "profile", "email")
- 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_verifierinsessionStorage, not component state - Generate a new
code_verifierfor each authorization request - Validate
stateparameter matches before exchanging the code - Log the
code_challenge_methodused (always S256, neverplain) - Use a single active PKCE session - reject overlapping authorization requests