PKCE started life as a workaround for a mobile-only problem: on iOS and Android, any app could register a custom URI scheme like myapp://callback, and OAuth authorization codes delivered over that scheme could be silently intercepted by a malicious app squatting on the same scheme. That origin story is why many engineers still file PKCE under "mobile stuff" and skip it in server-side web apps that hold a real client secret.

That mental model is now wrong in two ways. First, PKCE turned out to protect against a second, nastier attack — authorization code injection — that affects confidential clients too. Second, the standards have moved: the OAuth 2.0 Security Best Current Practice (RFC 9700), published January 2025, requires PKCE for all public clients and recommends it for confidential ones, and the in-progress OAuth 2.1 specification makes it mandatory for every client using the authorization code flow, full stop.

The attack PKCE was designed to stop

RFC 7636 — "Proof Key for Code Exchange," pronounced "pixy" — describes the original threat precisely. The authorization code grant has two legs: a front-channel redirect that delivers the code to the client, and a back-channel POST where the client exchanges the code for tokens. On a mobile OS, the front-channel leg terminates at whatever app registered the redirect URI's custom scheme. If a malicious app registers the same scheme as the legitimate app, the OS may hand it the authorization code.

For a public client there is no client secret worth the name — anything shipped in an app binary is extractable — so a stolen code is as good as a stolen token. The attacker replays the code at the token endpoint and walks away with the user's access token.

The structural problem: the code is a bearer credential during its short life, and it travels over a channel the client doesn't control.

The mechanism: a secret that never crosses the front channel

PKCE fixes this by binding the two legs of the flow together with a fresh secret that only ever travels on the back channel.

  1. Before starting the flow, the client generates a code_verifier: a cryptographically random string of 43 to 128 characters drawn from the unreserved URL character set (A-Z, a-z, 0-9, -, ., _, ~). RFC 7636 recommends deriving it from at least 256 bits of entropy — a 32-octet random sequence, base64url-encoded.
  2. The client derives a code_challenge from it. With the S256 method, that is exactly:
code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))
  1. The authorization request carries code_challenge and code_challenge_method over the front channel. The server stores them against the authorization code it issues.
  2. When the client exchanges the code at the token endpoint, it sends the original code_verifier. The server recomputes the hash and compares. Mismatch means invalid_grant — no tokens.

An attacker who intercepts the redirect now holds a code and a hash of the secret needed to redeem it. SHA-256 preimage resistance does the rest. The verifier itself never appears on the front channel, so there is nothing to steal there.

In practice this is a few lines of client code:

python
import base64, hashlib, secrets

verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=")
challenge = base64.urlsafe_b64encode(
    hashlib.sha256(verifier).digest()
).rstrip(b"=")
# send challenge + method=S256 in the authorization request,
# send verifier in the token request

Note the stripped = padding — base64url without padding is what the spec's BASE64URL-ENCODE means, and unstripped padding is a classic source of invalid_grant errors that "only happen with some providers."

Why S256 is the only method you should use

RFC 7636 defines a second transformation, plain, where the challenge simply equals the verifier. It offers no protection against an attacker who can observe the authorization request as well as the response. The spec is blunt about its status: plain "SHOULD NOT be used" and "exists only for compatibility with deployed implementations." Meanwhile S256 is mandatory to implement on the server, and a client that can compute SHA-256 — which is every client — "MUST use S256."

So the practical rule collapses to: always S256, never plain.

The attack most people miss: authorization code injection

If code interception were the whole story, confidential clients could shrug — an attacker without the client secret can't redeem a stolen code anyway. RFC 9700 spells out why they can't shrug, in the form of the authorization code injection attack (its Section 4.5).

Here the attacker doesn't try to redeem the stolen code themselves. Instead, they start their own session with the legitimate client and inject the victim's code into it — typically by swapping the code parameter on the redirect. The client, which does hold the correct secret, happily redeems the victim's code inside the attacker's session. The goal, in the RFC's words, is to "associate the attacker's session at the client with the victim's resources." The attacker ends up logged into the app as the victim, using an entirely legitimate token exchange.

PKCE breaks this because the verifier is bound to the session that started the flow. The attacker's session at the client has its own verifier; the injected code is bound to the victim's challenge. As RFC 9700 puts it: "the client uses its correct verifier, but the code is associated with a code_challenge that does not match this verifier." The token endpoint rejects the exchange.

This is why RFC 9700 — which formally updates RFC 6749 and RFC 6750 — states that "Public clients MUST use PKCE," authorization servers "MUST support PKCE," and even for confidential clients PKCE is RECOMMENDED. The client secret authenticates the client; it does nothing to bind a particular code to a particular end-user session.

PKCE that isn't enforced isn't PKCE

A subtle failure mode remains: the downgrade attack. If an authorization server supports PKCE but doesn't insist on it, an attacker who can strip or omit the code_challenge from an authorization request gets a code with no PKCE binding at all — and the token endpoint, seeing no stored challenge, may skip verification entirely.

RFC 9700 closes this with a server-side MUST: if there was no code_challenge in the authorization request, the server "MUST ensure that ... a request to the token endpoint containing a code_verifier is rejected." Mismatched halves of the protocol must fail loudly rather than degrade silently. If you operate an authorization server, this check — along with rejecting plain where you can — is the difference between offering PKCE and actually providing it.

OAuth 2.1 goes further and removes the option space. The current draft (draft 13, May 2025) says clients "MUST use code_challenge and code_verifier and authorization servers MUST enforce their use," with only a narrow carve-out — and even there PKCE stays RECOMMENDED. The same draft drops the implicit grant (response_type=token) and the resource owner password credentials grant entirely, and requires exact string matching of redirect URIs. The direction of travel is unambiguous: the authorization code flow with PKCE is the flow.

What this changes in your code

  • Web app with a backend and client secret? Add PKCE anyway. The secret doesn't stop code injection; the verifier does.
  • SPA or mobile app? PKCE is non-negotiable — RFC 9700 makes it a MUST for public clients.
  • Still sending state for CSRF? RFC 9700 says a client "MAY rely on the CSRF protection provided by PKCE" — but only if it has ensured the server actually supports PKCE. If it doesn't, state or the OpenID Connect nonce "MUST be used." When in doubt, keep state; it's cheap.
  • Running an authorization server? Support S256, enforce the downgrade check at the token endpoint, and start rejecting non-PKCE authorization code flows before OAuth 2.1 makes you.

PKCE costs one random string and one hash per login. Few security mechanisms buy this much for this little.

References