PKCE: Who Holds the Secret at Each Hop (Diagram)

The authorization code that a login provider hands back travels through the browser. That means it can end up in a redirect chain, a proxy log, a browser history entry, or a Referer header sent to whatever analytics script you installed last month.

PKCE assumes that code will leak, and makes it worthless anyway.

A sequence diagram of a PKCE login. Your server keeps a random verifier in the session and sends only its hash to the login provider, so an attacker who intercepts the one-time login code is rejected when they present it without the verifier, while your server completes the exchange by sending the code together with the verifier it kept.
The code crosses the open web. The verifier never does.Full explanationMermaid source

Reading the diagram

Four parties, and the whole lesson is which of them ever holds the verifier.

Step 1. Your server invents a random string, the verifier, and keeps it in the session. What it sends to the browser is only the SHA-256 hash of that string, called the challenge. This is the move that matters. A hash goes one way, so anyone who sees the challenge still cannot produce the verifier.

Steps 2 and 3. The browser carries the challenge to the login provider, and the provider hands back a one-time login code. Both of those hops happen over the open web, through a browser you don't control.

Steps 4 through 6 are the red band, and they're the attack. The code leaks somehow: a redirect that logged its query string, a Referer header, a shared machine's history. The attacker presents that code to the provider and gets nothing, because they can't supply the verifier that hashes to the challenge the provider stored at step 2. That rejection is the entire return on implementing PKCE.

Steps 7 through 9 are the legitimate exchange. The browser hands the code to your server, your server sends the code plus the verifier it has been holding all along, the provider hashes the verifier and compares, and only then issues a token.

The attacker and your server present the same code. One of them also has the verifier. That's the only difference, and it's enough.

The verifier has to stay somewhere the browser can't read. Putting it in localStorage or a readable cookie so the client can "finish the flow" removes the reason any of this works. Anyone who can read the code from the browser can then read the verifier from the same browser, and you're back to an interceptable login with extra steps. Keep it in the server session, keyed to that one attempt, and delete it after the exchange.

What PKCE does not do

It doesn't stop an attacker from starting a login flow and tricking you into completing it. That's CSRF against your callback route, and the state parameter is what handles it. The two get implemented together and mixed up constantly, so it's worth being blunt: PKCE protects the code, state protects the session that asked for it. Dropping either leaves a real hole.

It also doesn't help once the token exists. After step 9 you're holding an access token, and everything about how you store and expire it is a separate problem.

The Mermaid source

Copy this and adapt it to your provider.

---
title: "A stolen code is useless without the verifier"
---
sequenceDiagram
    autonumber
    participant S as Your server
    participant B as Your app<br/>in the browser
    participant P as The login<br/>provider
    participant A as An attacker who<br/>grabs the code

    S->>B: redirect carrying only the hash.<br/>the random verifier stays in the session
    B->>P: the hash, over the open web
    P-->>B: a one-time login code

    rect rgb(254, 242, 242)
        B-->>A: the code leaks through a redirect,<br/>a log, or a referrer header
        A->>P: the code, with no verifier
        P-->>A: rejected. no verifier, no token.
    end

    B->>S: the code
    S->>P: the code plus the verifier<br/>it kept all along
    P-->>S: access token

    Note over S,A: the code crossed the open web.<br/>the verifier never did.

What does PKCE actually protect against?

One thing: someone stealing the authorization code in transit and redeeming it themselves. That code travels back through the browser, so it can end up in a redirect chain, a server log, a history entry, or a Referer header sent to a third party. PKCE makes a stolen code useless on its own, because redeeming it also requires the verifier, which never left your server.

What is the difference between the verifier and the challenge?

The verifier is a random string your server generates and keeps. The challenge is its SHA-256 hash, and it's the only one of the two that travels to the provider at the start. You can't work backwards from a hash, so an attacker who sees the challenge can't produce the verifier. At the end your server sends the verifier, the provider hashes it, and it either matches what it stored or the exchange fails.

Do I still need the state parameter if I use PKCE?

Yes, and they're not interchangeable. PKCE stops a stolen code from being redeemed by someone else. The state parameter stops an attacker from tricking your app into completing a login flow it never started. Implement both.

Do I need PKCE if my app has a backend and a client secret?

Current OAuth guidance says to use it for all clients. A client secret proves which application is redeeming the code, not that it's the same session that started the flow. PKCE is what ties the redemption back to the browser session that began it.

Where should the verifier be stored?

In the server-side session, keyed to that single login attempt, and deleted once the exchange completes. Not localStorage, not a readable cookie, not a URL. Anywhere the browser can read it is somewhere an attacker reading that browser can read it too.

Is your login flow actually using PKCE?

A scan checks your deployed app's auth configuration and flags the OAuth mistakes that show up most in AI-generated code.

diagrams

PKCE: Who Holds the Secret at Each Hop (Diagram)