OAuth 2.0 PKCE: Making an Intercepted Authorization Code Useless
How PKCE's code_verifier and code_challenge stop authorization code interception: S256 math, server checks, why plain and downgrades fail, where state matters, and what PKCE does not cover.
In the OAuth 2.0 authorization code flow, the code reaches the client through a browser or operating system redirect. Unlike the call to the token endpoint, that hop is not something the client controls. PKCE (Proof Key for Code Exchange, RFC 7636) is the extension that keeps a leaked code from being redeemed by anyone else. This article walks through what PKCE actually proves, what the client and the server each have to enforce, and which problems remain after PKCE is in place, using the RFC texts as the reference.
Where the code travels
The authorization code flow in RFC 6749 Section 4.1 runs like this:
- The client sends the user’s browser (the user-agent) to the authorization endpoint with
client_id,redirect_uri,scope,state, and so on. - The authorization server authenticates the user and gets a decision on the access request.
- The authorization server redirects the browser back to
redirect_uri, addingcodeto the query. - The client sends that code to the token endpoint and asks for an access token.
- The authorization server checks the code and the redirect URI and issues tokens.
RFC 6749 says the code must expire shortly after issuance (a maximum lifetime of 10 minutes is recommended), must not be used more than once, and is bound to the client identifier and redirection URI. A confidential client also authenticates in step 4, so a stolen code on its own is hard to cash in.
Public clients are the problem. RFC 6749 Section 2.1 classifies installed native apps and in-browser apps as public clients, meaning they cannot keep their credentials confidential. With no secret involved, whoever holds the code can take it to the token endpoint.
The attack RFC 7636 addresses
RFC 7636 Section 1 uses a smartphone scenario. Native apps often use a custom URI scheme as their redirect URI. If a malicious app registers itself as a handler for the same scheme, it can receive the redirect in step 3, take the authorization code, and request a token itself.
The RFC lists the preconditions: the operating system must allow several apps to register the same custom scheme, the authorization code grant must be in use, and the attacker must know the client_id (and client_secret, if one was provisioned). It notes that every installation of a native app shares the same client_id and that secrets shipped in an app binary cannot be considered confidential. Despite the long list, the RFC states that the attack has been observed in the wild.
The three values PKCE adds
PKCE adds parameters to the authorization request and the token request so the server can verify that the token request presents the verifier corresponding to the challenge bound to the authorization code.
| Parameter | Sent in | Content |
|---|---|---|
code_challenge |
authorization request | Transformed code_verifier. Required |
code_challenge_method |
authorization request | S256 or plain. Defaults to plain if omitted |
code_verifier |
token request | A fresh secret the client creates per request. Required |
For every authorization request, the client creates and keeps a new code_verifier and sends only the transformed code_challenge. The authorization server associates that challenge and method with the code it issues. When the token request arrives, the server applies the same transformation to the submitted code_verifier and compares the result to the stored challenge. Whoever grabbed the code from the redirect does not have the verifier, so the exchange fails. RFC 7636 describes this as proof of possession of the code verifier.
code_verifier syntax and the S256 calculation
RFC 7636 Section 4.1 defines code_verifier as follows:
- Characters: RFC 3986 unreserved characters, i.e.
A-Z,a-z,0-9,-,.,_,~ - Length: at least 43 and at most 128 characters
- It SHOULD have enough entropy to make guessing impractical
The recommended way to create one is to take 32 octets from a suitable random number generator and base64url-encode them. Thirty-two octets is 256 bits, which encodes to exactly 43 characters without padding. Section 7.1 says the client SHOULD use at least 256 bits of entropy and stresses that the whole security model depends on the attacker not learning or guessing the verifier.
The S256 transformation is:
code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))
Base64url here means the URL- and filename-safe alphabet from RFC 4648 Section 5 (- instead of +, _ instead of /), with every trailing = removed and no line breaks or whitespace. Feeding in a plain base64 result, or leaving the padding on, will not match what the server computes. SHA-256 output is 32 octets, so an S256 challenge is also 43 characters long.
These are the example values from RFC 7636 Appendix B:
code_verifier = dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
code_challenge = E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
Authorization request: code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256
Token request: code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
If you wrote your own implementation, use this pair as a test vector: the same verifier must produce the same challenge.
Section 7.3 explains why the challenge is not salted. The verifier already carries 256 bits of entropy, so hashing it together with a public value would not increase the number of attempts needed to brute-force it. It resembles password hashing, but the premise differs from low-entropy passwords.
What the server must do
RFC 7636’s requirements for the server:
- When issuing a code, it MUST associate
code_challengeandcode_challenge_methodwith that code. It can encrypt them into the code itself or store them server-side, but it MUST NOT include the challenge in client requests in a form other entities can extract. - A server that requires PKCE MUST return
invalid_requestwhen the authorization request has nocode_challenge. The same applies when the requested transformation is not supported. - The token endpoint MUST verify
code_verifierwith the method that was bound to the code at issuance. On mismatch it MUST returninvalid_grant. - S256 is Mandatory To Implement on the server.
For compatibility, RFC 7636 Section 5 lets servers accept clients that do not use PKCE (MAY). That allowance is where the downgrade attack below begins.
Why plain is not equivalent to S256
With plain, code_challenge = code_verifier; no transformation happens, so the verifier travels in the authorization request.
RFC 7636 distinguishes two attacker capabilities. Against an attacker who can only observe responses from the authorization endpoint, plain is enough. Against one who can also observe requests, for example through HTTP logs leaked by the OS, a plain challenge is the verifier itself, so S256 or a cryptographically secure extension method is needed. RFC 9700 Section 3 likewise models an attacker who can read the authorization request (A4).
From this, RFC 7636 states:
- A client capable of S256 MUST use S256.
plainis permitted only if the client cannot support S256 for a technical reason and knows through out-of-band configuration that the server supportsplain. plainSHOULD NOT be used in new implementations.- Clients MUST NOT downgrade to
plainafter trying S256. Servers that support PKCE must support S256, and servers without PKCE ignore the unknown parameter, so an error on S256 can only mean a faulty server or a man-in-the-middle attempting a downgrade.
Also note that omitting code_challenge_method means plain. Forgetting the parameter silently gets you the weaker method.
RFC 9700 Section 2.1.1 states that S256 is currently the only method that does not expose the verifier in the authorization request.
The PKCE downgrade attack
RFC 9700 Section 4.8 describes an attack on servers that support PKCE but do not require it. It has two preconditions: the server applies PKCE only when code_challenge is present, and the client either does not use state or does not check it properly.
- The victim starts a login at the client. The client sends
code_challenge=hash(abc)and waits for the response. - The attacker starts a flow with the same client on their own device. The client generates
code_challenge=hash(xyz), and the attacker strips the parameter from the request entirely. It is their own device, so browser developer tools are enough. - The server treats this as a non-PKCE flow and issues a code that is not bound to any challenge.
- The attacker sends the victim’s browser to an authorization response URL carrying that code.
- The client tries to redeem the code along with the victim session’s
code_verifier=abc. - Since the code has no challenge, the server does not check the verifier and issues a token. The victim’s session is now linked to the attacker’s resources.
RFC 9700 says authorization servers MUST mitigate this. The check is simple: if the authorization request had no code_challenge, reject any token request that includes a code_verifier. Servers that mandate PKCE for all flows, or for particular clients, satisfy this implicitly. In addition, if a client sent a valid code_challenge, the server MUST enforce correct use of code_verifier at the token endpoint.
The boundary with state and nonce
RFC 6749 requires clients to implement CSRF protection for the redirection endpoint (MUST) and says they SHOULD carry the binding value in state. RFC 9700 Section 2.1 adds conditions:
- A client that has ensured the authorization server supports PKCE MAY rely on PKCE’s CSRF protection.
- In OpenID Connect flows,
nonceprovides CSRF protection. - Otherwise, one-time CSRF tokens carried in
stateand securely bound to the user agent MUST be used.
So “we use PKCE, we don’t need state” holds only after the server’s PKCE support has been confirmed. Section 4.7.1 says that if the server does not support PKCE, state or nonce MUST be used, and that the server MUST provide a way to detect its PKCE support; Authorization Server Metadata (RFC 8414) with code_challenge_methods_supported is the recommended route. As the downgrade attack shows, a client that drops state and trusts PKCE without server enforcement fulfills one of the attack’s preconditions. If state carries application state whose integrity matters, there is a separate requirement to protect it against tampering and swapping.
nonce also appears as an alternative to PKCE for authorization code injection. RFC 9700 allows confidential OpenID Connect clients, with additional precautions, to use nonce instead of PKCE (MAY), and is explicit that nonce does not protect codes issued to public clients: an attacker does not need to inject anything when they can call the token endpoint directly.
RFC 7636 versus RFC 9700
The two documents carry different requirement levels, so it helps to keep them apart.
| Topic | RFC 7636 (2015, Standards Track) | RFC 9700 (2025, BCP 240) |
|---|---|---|
| Who uses PKCE | Designed for public clients. Clients SHOULD send the parameters to all servers | Public clients MUST; confidential clients RECOMMENDED. Applies to all clients, including web apps |
| Server support | Servers MAY accept non-PKCE clients. If supported, S256 is mandatory to implement | Authorization servers MUST support PKCE |
| Method | MUST use S256 if capable; plain as an exception |
SHOULD use a method that does not expose the verifier; currently only S256 |
| Downgrade | Clients MUST NOT fall back to plain after S256 |
Server MUST reject a code_verifier for a code issued without a challenge |
| Advertising support | Not specified | MUST provide a detection method; publishing metadata RECOMMENDED |
| Nature of the value | Fresh verifier per request | Challenge MUST be transaction-specific and bound to the client and user agent; servers encouraged to detect constant values |
RFC 9700 is a BCP that updates RFC 6749, 6750, and 6819, and says it complements rather than supplants their security advice. It does not list RFC 7636 among the documents it updates, so the right-hand column is current best-practice guidance layered on top of RFC 7636, not normative text of RFC 7636 itself.
What PKCE does not cover
PKCE proves one thing: possession of the verifier corresponding to the challenge bound to this authorization transaction. Everything else is handled elsewhere.
- It does not authenticate the user. The authorization server does that in step 2 of the flow. PKCE says nothing about who logged in.
- It does not establish the client’s identity either. It binds the code to a particular client instance and transaction; it does not prove that the client is the legitimate app.
- It does not replace redirect URI validation. RFC 9700 requires the server to use exact string matching against registered redirect URIs (MUST), with an exception only for port numbers in localhost redirect URIs of native apps, and RFC 6749 requires the token request’s
redirect_urito match the authorization request. RFC 9700 Section 4.5.4 also notes that an attacker who can modify thecode_challengein the victim’s authorization request can circumvent PKCE and inject a code, which is why preventing authorization responses from leaking is still necessary. - It does not prevent open redirects. RFC 9700 separately says clients and authorization servers MUST NOT expose open redirectors.
- It does not protect tokens that have already been issued. The PKCE check happens only when the code is exchanged. A leaked access token calls for the sender-constraining (mTLS, DPoP) and audience restriction RFC 9700 recommends, and refresh tokens for public clients MUST be sender-constrained or rotated.
- It is not CSRF protection unless the server’s support has been confirmed, per the previous section.
- Clients that talk to more than one authorization server need a separate mix-up defense such as the
issparameter (RFC 9700 Section 2.1).
Implementation checklist
Client
- Generate a new
code_verifierfrom a cryptographic random source for every authorization request, and keep it only until that flow ends. - Send
code_challenge_method=S256explicitly. Omitting it meansplain. - Check against the Appendix B values that your base64url encoding drops padding and uses
-and_. - If S256 is rejected, do not retry with
plain. - Unless you have confirmed the server’s PKCE support through metadata or similar, keep using
statefor CSRF protection.
Authorization server
- Bind the challenge and method to each issued code and verify with that method at the token endpoint. A mismatch is
invalid_grant. - Reject a
code_verifierattached to a code that was issued without a challenge. Where possible, require PKCE on every flow. - Publish
code_challenge_methods_supportedin your metadata. - Keep the existing requirements too: exact redirect URI matching and short-lived, single-use codes.
Sources
- RFC 7636: Proof Key for Code Exchange by OAuth Public Clients (September 2015)
- RFC 9700: Best Current Practice for OAuth 2.0 Security (BCP 240) (January 2025)
- RFC 6749: The OAuth 2.0 Authorization Framework (October 2012)