Your authorization server ignores the `code_verifier` when the authorization request carried no `code_challenge` — what does that allow?
answer
- the attacker edits the first request
- a stripped challenge is a silent one
- check both directions, not one
- verifier present, challenge absent, refuse
- supporting is not requiring
basics
~20 sIt allows a PKCE downgrade. An attacker who can alter the front-channel authorization request strips the code_challenge, the server binds nothing to the code it issues, and an intercepted code is then redeemed with no verifier at all — reopening the hole PKCE closed.
solid answer
~40 sThe front channel is the tamperable leg. If an attacker can modify the authorization request, they remove `code_challenge` and `code_challenge_method`; the authorization server stores no commitment against the code, so anything holding that code can redeem it. RFC 9700 §2.1.1 asks for enforcement in both directions: an authorization server MUST enforce the `code_verifier` whenever a `code_challenge` was present, and it must also refuse a token request that presents a `code_verifier` for a code stored **without** a challenge — because that combination means the challenge was stripped between the client and the server. Supporting PKCE is not the same as requiring it; a per-client policy that demands a challenge on every authorization request is what actually closes the downgrade.
code
pseudocode · 17 linesfunction redeem(code, presented_verifier, client):
stored_challenge, stored_method = lookup(code)
if stored_challenge is absent and presented_verifier is present:
# a challenge was stripped from the authorization request
reject with invalid_grant
if stored_challenge is present:
if presented_verifier is absent:
reject with invalid_grant
if transform(presented_verifier, stored_method) not equal stored_challenge:
reject with invalid_grant
if stored_challenge is absent and policy_requires_pkce(client):
reject with invalid_grant
return token_response(code)go deeper
Take away the shape: if nothing was stored when the code was issued, nothing is checked when it is redeemed, and the protection quietly disappears.
Explain both directions of the check and why a verifier arriving for a challenge-free code is evidence rather than a harmless extra parameter.
Show the operational path: measure which clients send a challenge, add the tamper counter, then refuse challenge-free authorization requests per client rather than flipping a global switch.
Own the rollout decision — which registrations get a hard requirement first, what breaks, and how long an estate keeps accepting a weaker flow while it is being measured.
## The downgrade, step by step PKCE's protection is decided at authorization time, on the leg that travels through the browser. That leg is visible and modifiable to anything sitting between the client and the authorization server on the user's machine — the same position that lets an attacker intercept the code in the first place. 1. The legitimate client generates a `code_verifier`, derives a `code_challenge`, and issues an authorization request carrying the challenge and `code_challenge_method=S256`. 2. The attacker, positioned on that leg, deletes both parameters and passes the rest of the request on. 3. The authorization server sees an ordinary authorization-code request with no PKCE parameters, and issues a code with **no challenge stored against it**. 4. The attacker obtains the code from the authorization response, exactly as they would have before PKCE existed. 5. The attacker POSTs the code to the token endpoint with no `code_verifier`. The server has no stored challenge to check, so it issues an `access_token`. Nothing in that sequence is exotic. The attacker never breaks a hash and never guesses a verifier; they simply remove the question so that no answer is required. ## Why the honest client never notices The legitimate client is still doing everything correctly. It kept its verifier and it will send it at redemption — and a server that only checks a verifier when a challenge was stored will silently ignore it. If the attacker gets there first, the legitimate client receives `invalid_grant` for an already-redeemed code and, in most implementations, simply restarts the login. The user sees a login that occasionally needs a second attempt. There is no signal anywhere that the flow was downgraded. That is what makes the second check valuable: a `code_verifier` arriving for a code stored without a challenge is **evidence of tampering**, and the only party that can produce that evidence is the authorization server. ## The two checks, stated as a table | Stored against the code | What the token request carries | Required outcome | |---|---|---| | a `code_challenge` | a matching `code_verifier` | proceed to the token response | | a `code_challenge` | a non-matching `code_verifier` | refuse with `invalid_grant` | | a `code_challenge` | no `code_verifier` | refuse with `invalid_grant` | | no `code_challenge` | a `code_verifier` | refuse — the challenge was stripped in flight | | no `code_challenge` | no `code_verifier` | only if policy still permits a flow without PKCE | RFC 9700 §2.1.1 puts the requirement levels on this plainly: authorization servers **MUST** support PKCE, public clients **MUST** use it and it is **RECOMMENDED** for confidential clients, and the server **MUST** enforce the `code_verifier` when a `code_challenge` was present. Refusing a verifier presented against a challenge-free code is the mitigation for the downgrade specifically. ## Supporting PKCE is not requiring it The last row of that table is the policy decision, and it is where most estates are actually weak. A server that accepts PKCE when offered, and a flow without it when not, protects nobody against an attacker who can choose which request the server sees. Closing it means deciding, per client, that an authorization request without a `code_challenge` is refused outright — which turns the downgrade from a silent success into a failed authorization request that someone can see. Practical consequences of switching that on: - Every client of that registration must already be sending a challenge, or its logins stop. The safe order is to measure first — count authorization requests per client with and without a challenge — and only then enforce. - A client that sends a challenge only on some platforms will surface immediately, which is usually the point. - The refusal belongs at the authorization endpoint, where the user can be told a login failed, rather than at the token endpoint, where the client has already taken the user through a full approval. ## What this does not fix A downgrade check is not a substitute for the rest of the flow's protections. It does not stop a code being intercepted, only redeemed; it says nothing about which issuer answered the request, and nothing about what happens to the `access_token` afterwards. And it cannot help a client that never sends a challenge in the first place — for that client there is nothing to downgrade, and the only remedy is the policy that refuses it.
- How would you detect that a downgrade is being attempted against a client in production?Count, per client, token requests that present a `code_verifier` against codes stored with no `code_challenge`. For a client that always sends a challenge, that count should be zero, so any occurrence is either a stripped request or a client bug — and both are worth an alert. The same counter also tells you when it is safe to refuse challenge-free authorization requests outright.
- Should the refusal happen at the authorization endpoint or the token endpoint?Both, for different reasons. Refusing a challenge-free authorization request at the authorization endpoint is the policy control and fails early, before the user approves anything. Refusing a verifier presented against a challenge-free code at the token endpoint is the tamper detector, and it stays useful even where policy still allows flows without PKCE.
- Why is enforcing this on a client that never sends a challenge a different problem?Because there is nothing to downgrade. The two-way check compares what was stored with what was presented, and for that client both are empty on every flow, so the check is silent by construction. Only a policy that requires a `code_challenge` on the authorization request changes anything there.
saying these in an interview costs you the question
- Believes an attacker cannot alter a front-channel authorization request
- Treats a missing code_verifier as the client choosing to skip PKCE
- Checks the verifier only when the token request happens to carry one
- Thinks supporting PKCE is the same as requiring it
- Says a stripped challenge would make the legitimate login fail loudly