PKCE for Every OAuth Client: Why the Code Verifier Belongs in Your Server-Side App Too
Authorization codes travel through browser redirects and can be intercepted. PKCE makes a stolen code useless — here's the mechanics, a verifiable worked example, and why it belongs on confidential clients too.
27 Apr 2026, 14:40 UTC

An authorization code is a bearer credential that travels through a browser redirect. Anything that can observe that redirect — a malicious app registered for the same custom URI scheme on a phone, an injected script in a single-page app, a sloppy log that captures full URLs — can grab the code and try to redeem it at the token endpoint before your legitimate client does. That's the authorization code interception attack, and PKCE (Proof Key for Code Exchange, RFC 7636) exists to make a stolen code worthless on its own.
The useful takeaway: PKCE is cheap enough — one random string held across a redirect — that the modern engineering decision is to require it for every client type, including confidential server-side web apps, and to standardize on it across your whole OAuth estate.
How the interception actually works
In the Authorization Code flow, the authorization server sends the user agent back to your redirect URI with ?code=... in the query string. Your client then exchanges that code, back-channel, for tokens. The assumption baked into the original spec was that only your client could complete the exchange — for a confidential client, because it authenticates with a client secret.
That assumption breaks in two common places. On mobile, redirect URIs are often custom schemes like com.example.app:/callback, and another installed app can register the same scheme and receive the redirect. In SPAs, the code passes through a browser environment the client doesn't fully control. In both cases the attacker ends up holding a valid, unused code and a known token endpoint.
What the verifier buys you
PKCE binds the authorization request to the token request with a one-time secret that never travels through the front channel:
- The client generates a code_verifier: a high-entropy random string, 43–128 characters from the unreserved URL alphabet.
- It derives code_challenge = BASE64URL(SHA256(code_verifier)) and sends the challenge plus
code_challenge_method=S256on the authorization request. - After the redirect, it sends the original verifier on the token request. The server hashes it and compares against the stored challenge. No match, no tokens.
An attacker who intercepts the code has the challenge (it went through the browser) but not the verifier, and SHA-256 doesn't run backwards. The exchange fails.
A worked example with published test values
RFC 7636 ships a test vector you can verify yourself. Given this verifier:
code_verifier = dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXkthe S256 challenge is:
code_challenge = E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cMYou can confirm the derivation anywhere you have a shell — no credentials needed:
# Run locally; prints the base64url-encoded SHA-256 digest\nprintf '%s' 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk' \\\n | openssl dgst -sha256 -binary \\\n | openssl base64 -A | tr '+/' '-_' | tr -d '='The expected output is the challenge string above. If your implementation produces anything else, your base64url encoding (no padding, -/_ alphabet) is wrong — a common real-world bug.
The two requests then look like this:
# Authorization request (browser redirect)\nGET /authorize?response_type=code\n &client_id=YOUR_CLIENT_ID\n &redirect_uri=https://app.example.com/callback\n &scope=openid profile\n &state=RANDOM_CSRF_VALUE\n &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM\n &code_challenge_method=S256\n\n# Token request (back channel, after redirect)\nPOST /token\ngrant_type=authorization_code\n&code=CODE_FROM_REDIRECT\n&redirect_uri=https://app.example.com/callback\n&client_id=YOUR_CLIENT_ID\n&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXkTo verify behavior end to end, run the flow against a sandbox tenant of your identity provider and confirm the token request is rejected when the verifier is omitted or altered by one character. That negative test is the meaningful check — a passing happy path alone doesn't prove the server enforces PKCE.
Why confidential clients should opt in too
PKCE was standardized in 2015 with public clients (mobile, SPA) in mind. The security guidance that followed — including the OAuth 2.0 Security Best Current Practice document and the in-progress OAuth 2.1 draft — extends the recommendation to confidential clients as well. (Confirm the current status of those documents before citing specific numbers or dates; they have moved during drafting.) The reasoning is defense in depth: a client secret protects the token endpoint, but PKCE additionally binds the two legs of the flow together, so a code leaked through logs, referers, or a confused-deputy redirect still can't be redeemed by a third party.
There's also an operational argument. If you run web, mobile, and SPA clients against the same authorization server, requiring PKCE everywhere lets you standardize one client-library configuration instead of maintaining per-client-type exceptions.
Trade-offs and limits
The cost is small but real: the verifier must survive the redirect round trip. For a server-side app, keep it in the session next to your state value. For an SPA, a short-lived HttpOnly cookie set by your backend-for-frontend works; storing it in localStorage re-exposes it to the same script-injection risk you're defending against.
Three more constraints:
- Always use S256, never
plain. The plain method sends the verifier itself through the front channel, which defeats the point. Some older authorization servers only acceptplainor reject unknown method values — check your provider's current documentation. - PKCE is not a CSRF defense. You still need the
stateparameter to bind the redirect to the user's session, and exactredirect_urimatching on the server. These are separate protections for separate attacks. - PKCE protects the code, nothing after it. It does nothing against phishing, token theft post-issuance, or compromised token storage. Keep the threat model precise when you explain it to your team.
The actionable part
Check your OAuth client library: is PKCE on by default, or opt-in? Record the library version, flip it on for every client including your confidential web apps, and run the negative test — drop the verifier from the token request and confirm the exchange fails. If it succeeds, your authorization server isn't enforcing PKCE, and that's the finding worth fixing first.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.