What this usually means
The authorization server computed a hash of the code verifier (sent during token exchange) and compared it against the code challenge (sent during authorization request). The two values didn't match. This typically happens because the client used a different code verifier than the one used to generate the code challenge, the hash algorithm (S256 vs. plain) is inconsistent, or there's a base64 encoding/decoding discrepancy (e.g., URL-safe vs. standard base64, missing padding).
The first ten minutes — establish facts before touching code.
- 11. Capture the exact code challenge and code verifier from the client logs. Compare them manually.
- 22. Check if the code challenge method is 'S256' or 'plain'. If 'S256', recompute the hash using SHA-256 and base64url-encode without padding.
- 33. Ensure the code verifier is between 43 and 128 characters and uses only unreserved characters (A-Z, a-z, 0-9, '-', '.', '_', '~').
- 44. Verify that the authorization server supports the code challenge method you're using. Some servers only support 'plain'.
- 55. Check for timing issues: the code verifier must match the one used in the initial authorization request. If the client regenerates the verifier between steps, verification fails.
- 66. Review network logs for any transformation of the code challenge or verifier (e.g., URL encoding, case changes).
The specific files, logs, configs, and dashboards that usually own this bug.
- searchClient-side logs: print the code challenge, code verifier, and their hashed versions.
- searchAuthorization server logs: look for 'code_challenge' and 'code_verifier' entries.
- searchNetwork proxy (e.g., Charles, Wireshark): inspect the actual HTTP parameters sent.
- searchOAuth2 library source code: verify how the library computes S256 (e.g., does it use base64url vs base64? Does it strip padding?).
- searchToken endpoint request body: ensure the code_verifier parameter is present and correctly named.
- searchClient configuration files: check if the code challenge method is explicitly set or defaulting incorrectly.
Practical causes, not theory. These are the things you will actually find.
- warningS256 hash computed incorrectly: using SHA-256 then base64 instead of base64url without padding.
- warningCode verifier is regenerated between authorization request and token exchange.
- warningCode challenge method mismatch: client sends 'S256' but server only supports 'plain', or vice versa.
- warningBase64 encoding issues: using standard base64 instead of URL-safe base64, or including padding when server expects no padding.
- warningUnicode or encoding problems: non-ASCII characters in code verifier are encoded differently on client vs server.
- warningAuthorization code is reused or expired: the code challenge is associated with the auth code; if the code is stale, verification uses a different challenge.
Concrete fix directions. Pick the one that matches your root cause.
- buildStandardize on 'S256' method and ensure the hash is computed as: BASE64URL(SHA256(ASCII(code_verifier))) without padding.
- buildPersist the code verifier between the authorization request and token exchange (e.g., in session storage) instead of regenerating it.
- buildDouble-check library documentation: some libraries (e.g., older versions of Spring Security) use base64 with padding by default.
- buildIf using 'plain', ensure the code challenge is exactly the same string as the code verifier.
- buildAdd explicit logging of the computed hash and compare against the stored code challenge.
- buildTest with a known good client (e.g., Postman's PKCE flow) to isolate whether the issue is client-side or server-side.
A fix you cannot prove is a guess. Close the loop.
- verified1. Manually compute the expected code challenge from the code verifier using an online tool or script, then compare to the one sent in the authorization request.
- verified2. Perform a complete OAuth2 flow using a test client that logs all PKCE parameters.
- verified3. Use curl to simulate the token exchange with the exact code verifier and auth code, verifying the server response.
- verified4. Enable debug logging on the authorization server to see the computed hash and the stored code challenge.
- verified5. Run a regression test suite that includes PKCE flows with various code verifier lengths and characters.
- verified6. Monitor server logs for 'PKCE verification passed' after the fix to confirm the error stops.
Things that make this bug worse or harder to find.
- warningDon't assume the code challenge is stored correctly; check if it's URL-decoded or truncated.
- warningAvoid using different base64 implementations without verifying they produce identical output.
- warningDon't ignore padding: some servers accept padding, some don't. Be consistent.
- warningNever regenerate the code verifier between authorization and token exchange.
- warningDon't skip testing with edge cases: code verifiers with special characters, maximum length, or containing only dots.
- warningAvoid fixing the client without checking the server configuration; the server might have a bug too.
Mobile App Fails to Log In After Auth0 PKCE Update
Timeline
- 09:15User reports login fails on iOS 15, Android works fine.
- 09:30Check Auth0 logs: 'PKCE code challenge verification failed' for iOS requests.
- 09:45Compare iOS and Android code: both use AppAuth but different versions (iOS 5.0, Android 3.0).
- 10:00iOS sends code_challenge_method=plain, Android sends S256.
- 10:15Auth0 tenant configured to require S256. iOS using plain causes mismatch.
- 10:30Update AppAuth iOS to use S256: set additionalParameters with code_challenge_method=S256.
- 10:45Test login: still fails. Check network logs: code_challenge is base64url with padding.
- 11:00Auth0 expects no padding. Patch code to strip padding from hash.
- 11:15Login succeeds. Regression tests pass.
We had a report that iOS users couldn't log in after a recent Auth0 tenant update. Our React Native app used AppAuth for OAuth2 with PKCE. Android worked fine, so we suspected an iOS-specific bug. Auth0 logs showed 'PKCE code challenge verification failed' for iOS requests. I started by comparing the authorization requests from both platforms.
The iOS app was sending code_challenge_method=plain while Android sent S256. Our Auth0 tenant had been configured to require S256 for all clients. That was the first mismatch. I updated the iOS AppAuth configuration to use S256. But the error persisted. Next, I captured the exact code challenge and code verifier from the iOS network logs. The code challenge was base64url with padding, but Auth0 expects no padding. I had to manually strip the padding from the SHA-256 hash.
After that fix, the login worked. The lesson: always verify the exact encoding requirements (padding, URL-safe) and the code challenge method. Also, don't assume library defaults match server expectations. We added unit tests to enforce correct PKCE computation and a pre-flight check in CI that compares our hash against a known reference.
Root cause
AppAuth iOS defaulted to 'plain' method and base64url with padding, while Auth0 required S256 with no padding.
The fix
Explicitly set code_challenge_method=S256 and strip padding from the base64url-encoded SHA-256 hash.
The lesson
Always verify both the algorithm and encoding details (padding, URL-safe) for PKCE. Library defaults may not match your authorization server's expectations.
PKCE (Proof Key for Code Exchange) extends the OAuth2 authorization code flow to prevent interception attacks. The client generates a random secret called the code verifier (43-128 characters, unreserved characters). During the authorization request, the client sends a code challenge derived from the verifier. The derivation method is either 'plain' (challenge = verifier) or 'S256' (challenge = BASE64URL(SHA256(ASCII(verifier)))).
Later, during the token exchange, the client sends the original code verifier. The authorization server recomputes the challenge using the same method and compares it to the stored challenge. If they match, the client is authenticated. A verification failure means the recomputed hash doesn't match the stored one. This can happen if the verifier is different, the hash algorithm is inconsistent, or encoding differs.
The most frequent bug is incorrect base64 encoding. The specification requires base64url encoding without padding. Many libraries default to standard base64 with padding. For example, Java's Base64.getUrlEncoder() includes padding, so you must call withoutPadding(). Python's base64.urlsafe_b64encode() adds padding, so you need to decode and strip '=' characters. JavaScript's btoa() is not base64url and not safe for binary data.
Another pitfall is character encoding. The spec says the hash is computed on the ASCII representation of the code verifier. If the verifier contains non-ASCII characters (it shouldn't, but bugs happen), different platforms may encode them differently, leading to hash mismatches. Always validate that the verifier contains only allowed characters.
To isolate the issue, capture the exact HTTP requests on both legs. On the client side, log the code verifier and the computed code challenge (both raw and base64url without padding). On the server side, enable debug logging to see the stored challenge and the recomputed hash. Compare them byte by byte.
Use a script to manually verify: given the code verifier, compute the expected code challenge using the same algorithm. If your script produces a different result, the client implementation is wrong. If your script matches but the server fails, then the server might be storing or comparing incorrectly. Also check if the server supports the method you're using; some older servers only support 'plain'.
Sometimes the authorization server has bugs. For example, some servers incorrectly require padding, or they treat the code challenge as case-sensitive when it should be base64url (which is case-sensitive but not in a way that's ambiguous). Others may have a maximum length for the code verifier that's stricter than the spec (43-128).
Another server-side issue: the server might store the code challenge in a URL-decoded form, but then compare it against a URL-encoded verifier. If the challenge contains characters like %2B (for '+'), decoding can change the value. Always check that the server stores and compares the raw bytes, not URL-encoded strings.
To prevent PKCE failures, write integration tests that cover the full flow with known fixed values. Use a test authorization server (like UAA or Keycloak) that logs the computed hash. Also test edge cases: verifier with maximum length (128 chars), verifier with special characters (e.g., dots, tildes), and verifier that is exactly 43 chars (minimum).
Add a pre-commit hook that runs a script to compute the expected challenge for a sample verifier and compares it against the output of your OAuth2 library. This catches regressions when the library is updated. Also, ensure your deployment pipeline tests against the actual authorization server in a staging environment.
Frequently asked questions
What is the difference between 'plain' and 'S256' code challenge methods?
'plain' means the code challenge is exactly the same as the code verifier. This does not provide any proof of key possession and is only used for legacy compatibility. 'S256' means the challenge is the SHA-256 hash of the verifier, base64url-encoded without padding. S256 is the recommended method as it hides the verifier from eavesdroppers.
Why does my code challenge have a different hash when I compute it manually?
Common reasons: (1) You used standard base64 instead of base64url. (2) You included padding characters (=) but the server expects no padding. (3) The input to the hash is not ASCII (e.g., you passed a Unicode string without encoding to bytes). (4) You used a different hash algorithm (e.g., SHA-1 instead of SHA-256).
Can the code verifier be reused across multiple authorization requests?
No. Each authorization request must use a unique code verifier. Reusing the same verifier weakens security and may cause issues if the authorization code is captured. Always generate a new cryptographically random verifier for each flow.
Should I use PKCE even if my client is confidential (has a client secret)?
Yes. PKCE is recommended for all OAuth2 clients, even confidential ones, as a defense in depth. It protects against authorization code interception attacks, which can happen even with a client secret (e.g., if the secret is leaked or the redirect URI is compromised).
My server returns 'invalid_grant' but not specifically PKCE failure. How can I tell?
Check the server logs. If the error is due to PKCE, logs will typically mention 'code_challenge_verification_failed' or 'PKCE verification failed'. If logs aren't accessible, try sending a token request with an intentionally wrong code verifier. If you get the same error, it's likely a PKCE issue. You can also temporarily disable PKCE on the client to see if the error goes away (but don't do this in production).