skip to content

Why does PKCE's `S256` method protect an observed authorization request when `plain` does not?

level: middleimportance: should knowfreq 54%

answer

  1. which value actually travels?
  2. plain shows its hand up front
  3. a digest does not run backwards
  4. S256 is mandatory to implement
  5. omitted method means plain

basics

~10 s

With plain the code_challenge is the code_verifier itself, so whatever reads the authorization request holds both halves. S256 puts only BASE64URL-ENCODE(SHA256(ASCII(code_verifier))) on that request, and a digest cannot be turned back into the verifier.

solid answer

~40 s

RFC 7636 registers two values for `code_challenge_method`. With `plain`, `code_challenge` *is* the `code_verifier`, so the secret is published on the front channel; anything positioned to read the authorization request can then redeem an intercepted code. With `S256` the client sends `BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))`, a one-way digest that reveals nothing usable. `S256` is mandatory to implement on the authorization server, and a client capable of `S256` MUST use it — `plain` is permitted only where a client technically cannot. RFC 9700 records that `S256` is currently the only registered method that does not expose the verifier. One trap: if `code_challenge_method` is omitted, the value defaults to `plain`.

code

pseudocode · 13 lines
pseudocode
# client, once per authorization request
verifier  = base64url_no_padding(cryptographic_random(32))
challenge = base64url_no_padding(sha256(ascii(verifier)))
send authorization request with challenge and method "S256"

# authorization server, at the token endpoint
stored_challenge, stored_method = lookup(code)
if stored_method equals "S256" then
    computed = base64url_no_padding(sha256(ascii(received_verifier)))
else
    computed = received_verifier          # plain
if computed not equal stored_challenge then
    reject with invalid_grant

go deeper

for a junior

Know that two methods exist and that only one of them keeps the secret off the front channel; S256 sends a digest, plain sends the secret itself.

for a middle

Name the transformation exactly, explain why one-wayness is what matters, and know that a missing method parameter means plain rather than the stronger choice.

for a senior

Be able to review a live flow: capture one authorization request, check the method, and say which of the two failure stories a silent plain deployment or a broken login represents.

for a principal

Decide whether your authorization server should simply refuse plain for every client, and weigh that against whatever constrained clients you still have on the estate.

## Two registered transformations RFC 7636 defines exactly two values for `code_challenge_method`, and they differ only in what the client is prepared to put on the front channel. | `code_challenge_method` | What the authorization request carries | What an observer of that request learns | |---|---|---| | `plain` | the `code_verifier` itself | the verifier — that is, both halves of the pair | | `S256` | `BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))` | a digest, from which the verifier cannot be recovered | The attacker PKCE is written against is one that can get at the authorization code as it comes back. On a machine where something can reach the authorization response, it can very often reach the authorization request as well. `plain` publishes the secret to exactly that party, and an interceptor who holds both the code and the verifier redeems the code normally. The server's comparison succeeds, because everything it was given matches. ## What `S256` computes The transformation is small, and the precision matters because the two sides must agree byte for byte: - take the `code_verifier` **string** as it will be sent, and read its ASCII octets; - hash those octets with SHA-256, producing 32 octets; - base64url-encode the digest, without padding, producing 43 characters; - send that as `code_challenge`, with `code_challenge_method=S256`. At the token endpoint the authorization server repeats the same three steps on the `code_verifier` it received and compares the result with the challenge it stored. A one-way function is all the protection needs to be: the server never has to keep the verifier, and an observer of the challenge has nothing to replay. ## `plain` is weaker, not useless RFC 7636 is careful about the scope, and so should an answer be. `plain` still binds the code to a secret, and it still defeats an attacker who sees only the authorization **response** — someone who reads the code out of the redirect but never saw the request that started it. What it does not defeat is an attacker positioned to read the request too, which is the position the specification's own threat model assumes. That is why `plain` exists at all: it was kept for clients that genuinely cannot compute a SHA-256 digest, not as an equal choice. ## What the specification actually requires - The authorization server **MUST** support `S256`; it is mandatory to implement on the server side. - A client capable of using `S256` **MUST** use it. `plain` is permitted only where the client cannot support `S256` for a technical reason. - RFC 9700 records that `S256` is currently the only registered method that does not expose the `code_verifier` to an observer of the authorization request. - Nothing here is about key length or cipher strength: the requirement is one-wayness, so a client cannot be talked into publishing its secret. ## The default nobody reads `code_challenge_method` is optional, and when it is absent the value **defaults to `plain`**. That single line produces two very different production stories: 1. A client sends the raw verifier as the challenge and omits the method. Everything works, the flow looks healthy, and the deployment is running `plain` without anyone deciding to. 2. A client computes the `S256` digest, sends it as the challenge, and omits the method. The server stores the digest as a `plain` challenge, so at redemption it compares the raw verifier against the digest, they differ, and every single login fails with `invalid_grant`. The second case is loud and gets fixed within the hour. The first is silent, and it is the one to look for when reviewing a client: check the request that actually goes out, not the configuration that was intended, and confirm `code_challenge_method=S256` is on it. ## Spotting it in practice The review question is short: capture one real authorization request and read two parameters. If `code_challenge_method` is missing or reads `plain`, the deployment is relying on an attacker never having seen the request — an assumption the whole mechanism was written not to make. And if the challenge is 43 characters of base64url while the method says `plain`, the flow is not merely weak, it is broken, and the token endpoint will say so.

  • If the transformation is one-way, why does the authorization server need the method stored as well?
    Because it has to reproduce exactly what the client did. With `plain` it compares the verifier as it stands; with `S256` it hashes first. Deciding by inspection would be guesswork — a 43-character verifier and a 43-character digest look identical — so the method is captured with the challenge at authorization time and is not accepted from the redeemer.
  • Does using S256 remove the need for the verifier to be unpredictable?
    No. The digest hides the verifier from an observer, but a verifier an attacker can compute independently — derived from an install identifier, a timestamp, a counter — is recoverable without ever reading the request. One-wayness and entropy are separate requirements, and PKCE needs both.

saying these in an interview costs you the question

  • Believes plain and S256 differ only in verbosity
  • Says S256 encrypts the code_verifier rather than hashing it
  • Thinks the server must store the verifier to use S256
  • Assumes an omitted code_challenge_method defaults to S256
  • Claims plain is fine because the request travels over TLS