Security / security / authorization / oauth
PKCE and state Defend Different Things
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.
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.
the control did its job · the check ran and could not help here · the gap this threat used · nothing to check
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
statecompared, 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-forgottenstateis 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-serveror/.well-known/openid-configurationand readcode_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_requestat the authorization endpoint rather than a silent loss of protection. -
What does a failure look like in your logs? A PKCE mismatch is
invalid_grantat the token endpoint, per RFC 7636 §4.6 — the same error as an expired code, a reused code, and aredirect_urimismatch. If you see a rise ininvalid_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.