DeepConcepts

Security / security / authorization / oauth

PKCE and state Defend Different Things

The misconception

That PKCE superseded state, so a client using PKCE can drop it. PKCE does provide CSRF protection, but only for codes the authorization server actually bound to a challenge. A server that treats the presence of code_challenge as the switch that enables PKCE will happily issue a code with no binding, ignore the code_verifier that arrives with it, and hand the tokens over — which is precisely the case state would have caught.

14 min

PKCE did not replace state, and state was never a weaker version of PKCE. They bind different things. state binds the callback to the browser session that started the flow. PKCE binds the authorization code to the client instance that asked for it. Each defends against threats the other cannot see, and one of them has a hole that only the authorization server can close.

The panel is an authorization code flow. Pick a threat model, configure the client and the authorization server, and read the trace: every parameter sent, every check performed, and who ends up holding whose tokens. The summary at the bottom re-runs all four scenarios against the configuration you have set, so you can see the coverage change as you toggle one box.

Start with state only and step through the four scenarios. Then switch to PKCE only and do it again. Neither configuration covers all four, and they fail on different rows.

Threat model
The client
The authorization server
outcome
stopped at
tokens belong to
held by the session of
Flow trace

the control did its job · the check ran and could not help here · the gap this threat used · nothing to check

Coverage of this configuration, all four scenarios

The row that surprises people is the code is intercepted on the device with state only. The state check passes — and it should, because the flow genuinely was started in this browser by this user. Nothing about the callback is wrong. The problem is that a copy of the code went somewhere else, and state has no opinion about who else holds the code. Turn PKCE on and the second holder gets invalid_grant at the token endpoint, because a code is now useless without the secret that was hashed into the request that created it.

What each parameter binds

Both parameters are random values the client generates per flow, so they look interchangeable. They are not, because they travel differently and are checked by different parties.

state goes out in the authorization request and comes back in the callback, in the clear, and the client compares it. RFC 6749 §10.12 is explicit about what it is for: the value must bind the request to the user agent's authenticated state, so the client can tell that a callback landing on its redirection endpoint belongs to a flow this browser session actually started. It is a CSRF token for a redirect.

The PKCE verifier never appears in the authorization request at all. Only its transform does. RFC 7636 §4.2 defines the two methods — plain, where the challenge is the verifier, and S256, where the challenge is BASE64URL(SHA256(ASCII(code_verifier))). The verifier goes to the token endpoint, over TLS, direct from client to server, and the authorization server recomputes the transform and compares (§4.6). A mismatch is invalid_grant. So PKCE is not a CSRF token; it is a proof of possession that makes the code useless to anyone who did not originate the request.

That difference is the whole lesson. state answers "did this browser start a flow?". PKCE answers "did this client instance start this flow?". In the injection scenario the first question has a truthful yes and the answer is worthless.

PKCE was for native apps, and then it was for everyone

RFC 7636 §1 describes one attack and only one: authorization code interception. A native app receives its redirect over a channel the operating system does not protect — historically a custom URI scheme, which any other installed application could also register. The code arrives at the legitimate app and at whatever else claimed the scheme. TLS is irrelevant; the leak is after the TLS connection ends.

The extension is small: put a secret in the client, send only its hash in the request, present the secret at the token endpoint. A code without the verifier is inert. Note what this gives a public client — one with no client secret, because a secret shipped inside a mobile binary or a JavaScript bundle is not a secret. PKCE gives such a client a per-transaction secret instead of a permanent one, which is a strictly better trade.

RFC 9700 §2.1.1 then widens it: public clients MUST use PKCE, confidential clients SHOULD, and the RFC adds the note explicitly — although PKCE was designed to protect native apps, the advice applies to all kinds of OAuth clients, including web applications. The reason is §4.5: PKCE turns out to be the clean countermeasure to authorization code injection, a completely different attack from the one it was written for. A confidential web app with a real client secret is still vulnerable to injection, because the attacker is not impersonating the client — the legitimate client authenticates perfectly and redeems the wrong code on the attacker's behalf.

§4.5.2 is worth reading if you maintain an authorization server. It notes that a server storing the complete redirect URI per code and comparing it exactly would detect some injections — and that providers very often skip that check or pattern-match instead, "maybe because it doesn't seem to be security-critical from reading the specification." That is why the redirect_uri row in the trace is grey-passing in every scenario: it is a real check that does not happen to be the discriminating one here.

plain is a code_challenge parameter with nothing behind it

Set the method to plain and run the interception scenario. The parameter is present, the flow looks like PKCE in every log and every network trace, and the protection is gone. RFC 7636 §7.2 states the reason directly: with plain, the challenge is the verifier, so anything that can observe the authorization request holds the secret. On the device where a rival app can claim the redirect URI, that is not a stretch.

§4.2 makes S256 mandatory to implement on the server and requires clients to use it if they can. RFC 9700 §2.1.1 restates it as a property to select for: use a challenge method that does not expose the verifier in the authorization request, and note that S256 is currently the only such method. plain exists for constrained environments that genuinely cannot compute SHA-256, and for almost nothing else.

The verifier's entropy is the other half. §4.1 requires 43 to 128 unreserved characters, and §7.1 asks for a minimum of 256 bits of entropy — the canonical construction being 32 random octets, base64url-encoded to 43 characters. A "verifier" derived from a session id, a timestamp, or a constant is a parameter that satisfies the schema and defends nothing; §2.1.1 of RFC 9700 even asks authorization servers to make a reasonable effort to detect constant challenge values.

The downgrade: where dropping state costs you

Set PKCE only, choose the unrequested-callback scenario, and leave both server boxes off. The client is doing everything the modern guidance asks. The threat succeeds anyway, and the trace shows why in one line: the code that arrived was never bound to a challenge, so the server has nothing to compare and drops the code_verifier on the floor.

RFC 9700 §4.8 gives this its own name. It needs two conditions. First, the authorization server treats the presence of code_challenge as the switch that turns PKCE on — so an authorization request made without that parameter yields a code with no challenge bound to it. Second, the client is not using state, or is not checking it, because it is relying on PKCE for CSRF protection. Someone who can make their own authorization request against your client — which is anyone, that endpoint is public — can produce a code with no PKCE binding and hand it to a browser that is expecting one.

§4.8.2 puts the fix on the server, deliberately: "practice has shown that many OAuth clients do not use or check state properly. Therefore, authorization servers MUST mitigate this attack." The mitigation is the second checkbox — if no code_challenge was bound to a code, reject a token request that carries a code_verifier. A server that mandates PKCE for the client implements this implicitly, which is what the first checkbox models.

This is what RFC 9700 §2.1 means by a conditional permission: clients that have ensured that the authorization server supports PKCE may rely on the CSRF protection PKCE provides. "Have ensured" is load-bearing. §2.1.1 tells you the concrete way to check — code_challenge_methods_supported in the server's metadata document, from RFC 8414. If you have not read that field, you have not ensured anything, and state is a cheap way to not need to. Turn both boxes on in the panel and every row goes green under either configuration; that redundancy is the point.

Two smaller things the panel does not model but that live next door. state must be one-time use and bound to the browser session — the common client bug is generating it, sending it, and never comparing it on the way back, which is indistinguishable from not having it at all. And RFC 9700 §4.7.1 notes an asymmetry worth knowing: against an attacker who can read the authorization response, state is replayable into a forged callback while PKCE is not, because the verifier was never in the response to read. See how CSRF tokens bind to sessions and what the OIDC nonce covers that neither does.

Why the implicit grant went away

The implicit grant returned an access token directly in the authorization response, in the URL fragment. There is no code, so there is nothing for PKCE to bind — the extension is not merely unnecessary there, it is inapplicable. RFC 9700 §2.1.2 says clients SHOULD NOT use the implicit grant or any response type that issues access tokens in the authorization response, and gives the reasons: those tokens leak through the same channels a code does (§4.1 redirect URI validation, §4.2 referrer headers, §4.3 browser history), and a leaked token is immediately usable, whereas a leaked code still has to be redeemed at an endpoint that can refuse it.

§2.1.2 adds the argument that is easy to miss: there is no standardised way to sender-constrain a token that was issued in the authorization response, so the authorization server has no means of binding it to the client that was supposed to receive it. Every mitigation available for codes — one-time use, PKCE binding, replay detection, client authentication at the token endpoint — exists because the code has to come back to the server. Handing out the token in the first hop removes the server from the loop. The replacement is the code grant with PKCE, for browser apps too.

Checking your own client

Five things, in the order they are usually wrong:

  • Is state compared, or only sent? Search for where the value is generated, then find the callback handler and confirm it reads the stored value and compares it. A generated-and-forgotten state is the most common finding in this area, and it looks correct in every network trace.
  • Is the verifier per-flow and random? Check the construction, not the parameter: 43–128 unreserved characters from a CSPRNG, one per authorization request, discarded after use.
  • Is the method S256? Grep the client for the literal plain. Then fetch your provider's /.well-known/oauth-authorization-server or /.well-known/openid-configuration and read code_challenge_methods_supported. If S256 is not listed, your client is not getting the protection it thinks it is, whatever it sends.
  • Does the server enforce PKCE for your client? Most providers have a per-client toggle, often named something like "require proof key" or "PKCE required". Turn it on. It closes the downgrade for you and turns a client regression into an invalid_request at the authorization endpoint rather than a silent loss of protection.
  • What does a failure look like in your logs? A PKCE mismatch is invalid_grant at the token endpoint, per RFC 7636 §4.6 — the same error as an expired code, a reused code, and a redirect_uri mismatch. If you see a rise in invalid_grant, you cannot tell those apart from the OAuth error alone, and the authorization server's own logs are the only place the distinction exists.

The one test worth automating: start a flow, capture the callback, and replay the same code a second time. A correct server refuses the second redemption. Then start a flow and redeem the code with a different verifier; that must be invalid_grant too. If it succeeds, PKCE is not being enforced on your codes and no amount of client-side correctness will change that.

A single-page app uses the authorization code flow with PKCE and S256, and dropped state on the grounds that PKCE covers CSRF. The identity provider supports PKCE but does not require it for this client. What is still open?

Next, what the client does with the tokens once it has them: why verifying a token's signature is not validating it, and the flow underneath all of this, the authorization code grant itself.

Why this concept is on the site

Topics are chosen from places engineers visibly get stuck, and the sources are kept with the lesson so the claim is checkable.