skip to content

OAuth 2.1 requires PKCE even from a confidential client holding a client_secret — what does that secret not prove?

level: seniorimportance: must knowfreq 56%

answer

  1. two jobs, one credential
  2. static per client, not per request
  3. authenticates the redeemer, not the code
  4. code injection into a genuine callback
  5. recommended for confidential, then required

basics

~20 s

A client_secret proves which client is redeeming an authorization code, not which request produced it. The same value is sent on every flow, so it cannot stop a code obtained elsewhere being injected into a legitimate client's callback and redeemed there.

solid answer

~50 s

Client authentication and code binding are two different jobs, and RFC 6749 gave a confidential client only the first. A `client_secret` is static and per-client: it tells the `token_endpoint` *which* registered client is redeeming, which is what stops an unregistered party spending a stolen `authorization_code`. It says nothing about *which* authorization request the code came from. So a code issued for somebody else's flow — reaching the client through a leaked response, a claimed callback or a captured URL — is redeemed by the genuine client with its own secret, and every check passes. PKCE closes that by making the token endpoint demand a per-request value only the party that started the flow ever held. RFC 9700 §2.1.1 makes PKCE a MUST for public clients and RECOMMENDED for confidential ones; OAuth 2.1 extends the requirement to the authorization-code grant generally.

code

http · 6 lines
http
POST /token HTTP/1.1
Host: as.allotment-society.example
Content-Type: application/x-www-form-urlencoded
Authorization: Basic czZCaGRSa3F0Mzo3RmpmcDBaQnIxS3REUmJuZlZkbUl3

grant_type=authorization_code&code=SplxlOBeZQQYbYS6WxSbIA&redirect_uri=https%3A%2F%2Fallotment.example%2Fcallback&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk

go deeper

for a junior

Recall that an authorization code is a one-time value swapped for a token, and that OAuth 2.1 wants a second per-request check on that swap even when the client has a password of its own.

for a middle

Explain the difference between a credential that identifies a client on every request and a value created fresh for one flow, and say which of the two survives a code arriving from somebody else's authorization request.

for a senior

Demonstrate the injected-code path end to end and name what passes at each step, then check the deployment does more than accept a proof key when offered — it has to reject a redemption that lacks one.

for a principal

The judgment is about what you make mandatory across an estate and what you allow as an exception, given that the weakest client determines what an attacker will target first.

## Two jobs that look like one A confidential client redeeming an authorization code presents two things that are easy to run together: something that says **which client this is**, and something that says **which flow this code came from**. RFC 6749 gave a confidential client only the first. - A `client_secret` is **static and per-client**. The same value accompanies every code this client ever redeems, so it can distinguish this client from another client — and nothing finer than that. - A PKCE proof key is **per-request**. It comes into existence when the flow starts and is only ever held by the party that started it, so it distinguishes *this* flow from another flow at the same client. An answer that says PKCE authenticates the client has collapsed the two. It does not: a client that authenticates perfectly can still redeem somebody else's code, which is exactly the case 2.1 is closing. ## The failure the secret does not cover Take the injected-code case concretely. A code is issued for an attacker's own authorization request and then delivered into a legitimate client's redirection endpoint — through a leaked authorization response, a callback claimed by a rogue application on the same device, or a URL recovered from a log or a referrer. The client behaves entirely normally: it receives what looks like its own callback and redeems the code at the `token_endpoint` **using its own `client_secret`**. Everything the secret can check, passes. | Check | What it uses | Outcome on an injected code | |---|---|---| | Client authentication | `client_secret` | Passes — the genuine client is redeeming | | Registered redirection URI | the client's registration | Passes — delivery was to the client's own URI | | Code binding | the per-request proof key | **Fails** — the code belongs to another flow | With the first two passing and no third check present, the attacker's authorization ends up attached to a session at the legitimate client. RFC 6749 had no third check to offer. ## What changed, and how strong each statement is - **RFC 7636** defines the proof-key mechanism as an extension, originally motivated by code interception on native applications. - **RFC 9700 §2.1.1** makes it a **MUST for public clients** and **RECOMMENDED for confidential clients**. That split is what an integration written against the best current practice will have implemented. - **OAuth 2.1** raises it to a requirement on the authorization-code grant generally, with a narrow carve-out for a client that already carries an equivalent defence against code injection. Quoting the middle row as though it applied to every client erases exactly what 2.1 contributed, and it is the most common inaccuracy on this subject. ## Why a confidential client was ever thought exempt The exemption argument runs like this. A public client has no secret, so an intercepted code is redeemable by whoever holds it. A confidential client's code is useless without the secret. Therefore the confidential client is already covered. The first two clauses are true and the conclusion does not follow. The threat PKCE addresses for a confidential client is not *someone else redeems this code* — it is *the right client redeems the wrong code*. Client authentication is the wrong instrument for that, because the client doing the redeeming is genuine and its credentials are genuine. ## What the secret is still for Requiring PKCE does not make client authentication redundant, and an answer that swings that far is wrong in the other direction. - It prevents an unregistered party from using the `token_endpoint` as this client at all. - It gives the authorization server an authenticated identity to rate-limit, audit and revoke. - It is what makes a leaked code useless to a third party who is not the client. - On the `client_credentials` grant there is no user flow to bind to, so the credential is the whole of the client's identity. The two mechanisms answer different questions, and 2.1 wants both answered on the authorization-code grant. ## What to check in a real integration An integration written before 2.1 usually shows a characteristic shape. The mobile or browser client sends a proof key, because a library made it do so. The server-side web client does not, because a developer reasoned that it had a secret and was therefore covered. That is precisely the configuration the 2.1 requirement closes. The check is per client rather than per product, and it has two halves: 1. Does every client using the authorization-code grant start its flow with a proof key? 2. Does the `token_endpoint` actually **reject** a redemption that arrives without one, rather than ignoring the absence? The second half is the one that gets skipped. A server that accepts the proof key when offered and shrugs when it is missing has implemented the mechanism and not the requirement.

  • If a confidential client's secret does not bind the code, what does it actually buy?
    It stops an unregistered party redeeming at the token endpoint as that client at all, and it gives the authorization server an authenticated identity to rate-limit, audit and revoke. That is real and worth keeping. It is simply a different property from binding one code to one flow, which is why 2.1 wants both.
  • The 2.1 PKCE requirement has a carve-out. What kind of client does it exist for?
    One that already carries an equivalent defence against having another party's authorization code injected into its callback, so the protection is present under a different name rather than absent. The carve-out is narrow and the safe default is to send the proof key regardless; a client that simply omits PKCE and calls itself exempt has not met it.

A locksmith's company badge proves which firm sent the van, and it is the same badge tomorrow; the job reference proves this van was sent to this address today. A client secret is only ever the badge.

saying these in an interview costs you the question

  • Says PKCE authenticates the client to the authorization server.
  • Thinks PKCE is only for clients that cannot keep a secret.
  • Claims a client_secret binds the code to the request that started it.
  • Says TLS on the token request makes code injection impossible.
  • Treats RFC 9700 as already requiring PKCE from confidential clients.
  • Assumes a server that accepts a proof key is enforcing one.