Internet Protocols · Identity Protocols
PKCE: Binding an Authorization Code to the Client That Asked for It
How the code verifier and S256 transformation stop intercepted codes from being redeemed, and why the plain transformation is a fallback rather than an equal.
An authorization code issued to a public client has no protection beyond its short lifetime and its single-use rule. The code travels through the browser, and any party that can observe that browser, or that can register itself as a handler for the client’s custom URI scheme, can obtain it. The code is bound to the client identifier, but a public client’s identifier is public: it appears in the authorization request. So the binding proves nothing.
Proof Key for Code Exchange changes the exchange so that the code alone is insufficient. The client generates a secret at request time, sends a derived value with the authorization request, and proves possession of the original secret at the token endpoint. An interceptor who captures the code captures a derived value that they cannot invert.
One secret, two representations
The client generates a random code_verifier: RFC 7636 specifies 43 to 128 characters from the unreserved set [A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~". The lower bound of 43 characters is not arbitrary; it corresponds to 256 bits of entropy when the verifier is base64url-encoded output of a random source, which is the recommended construction.
The client then computes the code_challenge, either as the verifier itself (the plain transformation) or as BASE64URL-ENCODE(SHA256(ASCII(code_verifier))) without padding (the S256 transformation). The challenge goes in the authorization request; the verifier stays with the client until the token request.
verifier dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
S256 E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
That pair is the worked example from the specification, and it is worth checking against an implementation: a client whose output does not match it has a padding or encoding error, most often a trailing = left on the base64url output or a newline appended by a hashing helper.
The authorization request gains two parameters:
GET /authorize?response_type=code
&client_id=s6BhdRkqt3
&redirect_uri=https%3A%2F%2Fclient.example.org%2Fcb
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256 HTTP/1.1
Host: as.example.net
The authorization server stores the challenge alongside the code, associates it with the code’s lifetime, and at the token endpoint recomputes the transformation over the presented code_verifier and compares the result to the stored challenge. A mismatch is invalid_grant.
Why interception alone is no longer enough
The attacker’s position is the key. Consider a mobile application registered to handle the custom scheme com.example.app:/callback. Any application on the device can declare the same scheme, and the operating system may present a choice or, depending on platform and version, may deliver the redirect to the wrong handler. The attacker’s application receives the code.
Without PKCE, the attacker sends the code to the token endpoint with the public client identifier and receives a token. The code is bound to the client, and the attacker is impersonating that client identifier exactly.
With PKCE, the attacker has the code and the challenge, both of which were visible in the authorization request or in the redirect. Neither yields the verifier: the S256 transformation is a SHA-256 preimage problem over a 256-bit random input. The token endpoint rejects the exchange because the attacker cannot present a verifier whose hash matches.
The same reasoning covers a compromised browser extension reading the redirect URL, a malicious application on a desktop machine that has registered the loopback redirect, and a code leaked through a Referer header. In each case the code is captured and the exchange is not possible.
Plain is a compatibility path, not an option
plain sends the verifier itself as the challenge. It provides nothing against an attacker who observes the authorization request, since the value they see is the value the token endpoint expects. Its only remaining benefit is that an attacker who obtains the code without seeing the request, for example from a log on the client side, still cannot use it without the verifier.
RFC 7636 permits plain because early clients could not compute SHA-256 in every environment. RFC 9700 states that S256 is required and that plain should not be used, and that an authorization server which supports plain at all should do so only for compatibility. The practical rule for an authorization server is to accept S256, treat a missing code_challenge_method as plain for backward compatibility, and refuse plain unless a specific registered client needs it.
There is a subtler reason to prefer S256 beyond preimage resistance. Some authorization servers log the full authorization request for debugging. With plain, that log contains the verifier and therefore the ability to redeem any code that appears in the same record. With S256, the log contains only the hash.
Downgrade and the missing-challenge case
The interesting failure is not a wrong verifier; it is a missing one. If the authorization server accepts a token request that omits code_verifier for a code that was issued with a challenge, the protection evaporates entirely: an interceptor simply omits the parameter and succeeds.
RFC 7636 requires the server to reject a token request that lacks the verifier when the code was issued with a challenge, and RFC 9700 goes further, requiring authorization servers to reject authorization requests from public clients that do not include a challenge at all. Without that second rule, an attacker can strip the code_challenge parameters from the authorization request as it passes through a hostile client or a manipulated browser, and the resulting code has no binding.
The stripping attack is why the challenge must be covered by whatever integrity the request has. In the plain code flow the authorization request has none, so a server that requires PKCE for public clients is relying on the client to notice. A client that receives a callback for a request it did not send, or receives one without having stored a verifier, should discard it.
Interaction with the redirect and with confidential clients
PKCE does not replace exact redirect URI matching, and it does not replace state. The three protect different things: exact matching constrains where the code can be delivered, state binds the callback to the user’s session and prevents login CSRF, and PKCE binds the code to the client instance that requested it. The first two are properties of the authorization code flow, and a deployment missing any one of the three has a specific hole that the others do not close.
Confidential clients also benefit, which is why RFC 9700 recommends PKCE for them as well. A confidential client’s secret is a long-lived shared credential; if it leaks, every authorization code the client handles is redeemable by the holder. PKCE adds a per-request secret that never persists, so a leaked client secret alone does not permit redeeming a code that was issued with a challenge. The cost is one extra hash per authorization and one extra stored value per pending request.
Storage of the verifier is a per-request obligation. A client that keeps a single verifier in a module-level variable breaks when two authorization requests are in flight, which happens routinely when a user opens two tabs. The verifier must be keyed by the state value or by a session identifier, and removed when the callback is processed, whether it succeeds or fails.
What PKCE does not fix
PKCE assumes the client can keep the verifier until the exchange. A client that writes the verifier to a cookie readable by JavaScript, or to a log, has recreated the problem in a different place. The protection is only as good as the storage.
It also does not protect the access token after issuance. A token stolen from a client’s memory or from a resource server’s logs is usable by anyone, because the bearer model applies. Binding the token itself to a key, through mutual TLS certificate binding or a proof-of-possession scheme, is a separate mechanism operating after the code exchange completes.
Finally, PKCE does not authenticate the client to the authorization server. It proves that the party redeeming the code is the party that started the request. A public client is still unidentified in any meaningful sense, which is why a public client’s access is limited by what the resource owner consented to rather than by what the client claims to be. That consent step, and what it actually records, is the subject of scopes and consent granularity.