PKCE Turns an Intercepted OAuth Code Into a Dead End
PKCE binds an OAuth authorization code to the client that requested it, so an intercepted code cannot be exchanged for tokens. Here is the mechanism, a local test exchange, and the trade-offs.
09 Dec 2025, 14:21 UTC

A code in the redirect is a bearer credential
In the OAuth 2.0 authorization code flow (RFC 6749), the authorization server sends the user back to the app with a short-lived authorization code in the URL. The app then exchanges that code at the token endpoint for an access token. On a public client—a browser single-page app, a mobile app, or a desktop app—there is no confidential client secret. Any secret shipped inside the app can be extracted. If an attacker can intercept the redirect, they can take the code and exchange it themselves. On mobile, a malicious app can register the same custom URI scheme. In a browser, a compromised redirect target or a leaked URL can expose the code.
PKCE binds the code to the client that requested it
Proof Key for Code Exchange (PKCE, RFC 7636) adds a one-time secret to the flow. The client generates a random code_verifier, derives a code_challenge from it—normally BASE64URL(SHA256(code_verifier))—and sends the challenge with the authorization request. At the token endpoint, the client sends the original verifier. The authorization server recomputes the challenge and compares. An intercepted code is useless without the verifier, which never travels in the redirect.
Use code_challenge_method=S256. The plain method exists for constrained environments where SHA-256 is unavailable, but it offers much weaker protection because the challenge is the verifier. PKCE does not replace the state parameter: state carries per-request data and defends against cross-site request forgery on the redirect. Keep both. Also register exact redirect URIs and match them as exact strings; prefix or wildcard matching widens the surface for code leakage and open redirectors.
A local test exchange
Run these commands in a shell on a development machine against a local or test authorization server. No elevated permissions are required. You need a registered client_id and an exactly registered redirect_uri. Replace the uppercase placeholders.
VERIFIER=$(openssl rand -base64 96 | tr '+/' '-_' | tr -d '=\n' | cut -c1-64)
CHALLENGE=$(printf '%s' "$VERIFIER" | openssl dgst -binary -sha256 | openssl base64 -A | tr '+/' '-_' | tr -d '=')
printf 'verifier length: %s\n' "${#VERIFIER}"
Check that the verifier length is between 43 and 128 characters and contains only unreserved characters. Then build the authorization URL and open it in a browser:
https://AUTH_SERVER/authorize?response_type=code
&client_id=CLIENT_ID
&redirect_uri=REDIRECT_URI
&scope=openid%20profile
&state=STATE
&code_challenge=$CHALLENGE
&code_challenge_method=S256
After authentication, the browser is redirected to REDIRECT_URI?code=AUTHORIZATION_CODE&state=STATE. Verify that the returned state matches the value you sent before using the code. Then exchange the code from your terminal:
curl -s -X POST https://AUTH_SERVER/token \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d grant_type=authorization_code \
-d code=AUTHORIZATION_CODE \
-d redirect_uri=REDIRECT_URI \
-d client_id=CLIENT_ID \
-d code_verifier="$VERIFIER"
Expected checks: the response should contain an access token and may contain a refresh token depending on provider policy and requested scope. Replaying the same code should fail with invalid_grant. Sending a different verifier should also fail. A redirect URI that is not an exact match to the registered value should be rejected. Do not log real tokens or the verifier; treat the authorization code as single-use and short-lived.
What PKCE does not fix
PKCE does not stop a malicious app that can register the same custom URI scheme on a mobile device. Where the platform supports it, use claimed HTTPS redirects or app links, and keep exact redirect URI matching. PKCE also does not make refresh tokens safe by itself. Short-lived access tokens limit exposure, but the long session lives in the refresh token. Rotating refresh tokens and detecting reuse of a superseded token limit the window if one leaks—but rotation requires server-side state and a reuse-detection policy. Without detection, rotation alone is weaker than it looks.
At the resource server, you still have to validate tokens. The table compares two common approaches.
| Check | Local JWT validation | Introspection (RFC 7662) |
|---|---|---|
| Latency | No network call per request | Network call per request, or cache with a short TTL |
| Key material | Needs the issuer's public keys and rotation handling | Needs client credentials to call the introspection endpoint |
| Freshness | Relies on exp and short lifetimes; revocation is not immediate | Reflects current server state at call time |
| Audience check | You must validate aud yourself | Response includes audience and scope, but still check them |
Checking aud matters: a token minted for one API should not be replayable at another. Token lifetimes and formats are policy choices, not protocol requirements, so avoid treating any specific minute value as mandated.
How to check your implementation
- Inspect the authorization request: confirm
code_challengeandcode_challenge_method=S256are present. - Inspect the token request: confirm
code_verifieris present and no client secret is sent for a public client. - Against a local test server, attempt a mismatched
redirect_uriand a replayed authorization code; expect rejection. - Decode a sample JWT access token and check that
audmatches your API, or call introspection and checkactive=true. - Read the provider's discovery document and documentation for supported grant types and PKCE enforcement before generalizing.
Spec status moves. The OAuth 2.0 Security Best Current Practice and the OAuth 2.1 draft change which grants are recommended, and provider behavior varies—some authorization servers still require a client secret for SPAs, and S256-only enforcement differs. Confirm the current text and your provider's behavior before asserting that implicit is removed everywhere. Library defaults also change between major versions and can silently enable or disable PKCE, so tie your checks to explicit configuration rather than a library name.
If you maintain a public client, the practical move is authorization code plus PKCE with S256, an exact redirect URI allowlist, a state value, short-lived access tokens, rotating refresh tokens with reuse detection, and audience validation at the resource server. Then test the failure paths, not just the happy path.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.