PKCE (pronounced “pixie”, short for Proof Key for Code Exchange) is an extension to the OAuth 2.0 authorization code flow. It exists because a huge class of clients — single-page apps, mobile apps, CLIs — cannot keep a secret, and the original authorization code flow was designed assuming they could.
The Problem With Public Clients
OAuth 2.0’s authorization code flow was built for confidential clients: server-side web apps that hold a client_secret nobody else can see. The server presents that secret when it exchanges an authorization code for tokens, so even if the code leaks in transit, an attacker without the secret can’t redeem it.
Public clients don’t have that luxury. A mobile app’s binary can be decompiled. A single-page app’s JavaScript runs entirely in the browser. Any secret embedded in either is not a secret — it’s a constant that ships to every user and every attacker who bothers to look. Before PKCE, this left the authorization code exposed to interception: a malicious app on the same device could register the same custom URL scheme, catch the redirect containing the code, and swap it for tokens without ever touching the legitimate app.
Code Verifier and Code Challenge
PKCE closes that hole by having the client generate a fresh, random secret for every single authorization request, instead of reusing a static one. This secret is called the code_verifier. The client also derives a code_challenge from it and sends that in the initial authorization request. Only at the very end, when exchanging the code for tokens, does it reveal the raw verifier.
import { webcrypto as crypto } from 'node:crypto';
function base64url(bytes: Uint8Array): string {
return Buffer.from(bytes)
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
export async function createPkcePair() {
const verifierBytes = crypto.getRandomValues(new Uint8Array(32));
const codeVerifier = base64url(verifierBytes);
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(codeVerifier));
const codeChallenge = base64url(new Uint8Array(digest));
return { codeVerifier, codeChallenge, method: 'S256' as const };
}
The authorization server stores the code_challenge alongside the issued code. When the client later calls the token endpoint, it sends the original code_verifier. The server hashes it and checks that the result matches the stored challenge. An attacker who intercepted only the authorization code — not the verifier, which never left the legitimate client until the final exchange — has nothing they can redeem.
The Full Flow
Notice the challenge travels over the redirect (visible to anything watching the browser or the OS-level redirect handler), but the verifier only ever travels once, directly from the client to the token endpoint, typically over a channel that’s harder to intercept than a URL redirect.
S256 vs Plain
The spec defines two challenge methods. You should only ever use one of them.
| Method | How challenge is derived | Security |
|---|---|---|
S256 |
BASE64URL(SHA256(verifier)) |
Verifier never appears in the authorization request, even hashed reversal isn’t feasible |
plain |
challenge = verifier |
Challenge equals the verifier, so anything that can see the authorize request sees the secret |
Where to Store the Verifier
The verifier has to survive the round trip from “app opens the browser” to “browser redirects back to the app,” which for a SPA usually means a page navigation, and for a native app means switching to a system browser and back.
Decision
Store the code verifier in memory
sessionStorage, cleared immediately after the exchange
For a SPA, sessionStorage survives a full-page redirect but is scoped to the tab and cleared
when it closes, which limits the exposure window compared to localStorage. It’s still
readable by any script on the page, so it’s not a defense against XSS — PKCE protects against
code interception, not a compromised client. Native apps have it easier: they can hold the
verifier in process memory for the lifetime of the login attempt.
PKCE Is Not Optional Anymore
Early guidance treated PKCE as an extra layer for public clients specifically. Current best practice — reflected in the OAuth 2.1 draft — is to require PKCE for every client, confidential or not. It costs almost nothing to implement and it closes the authorization code injection attack even for server-side apps, where an attacker who compromises a redirect URI or a network path could otherwise inject their own authorization code into a victim’s session.
Common Implementation Mistakes
A few mistakes show up repeatedly in the wild:
- Generating the verifier with
Math.random()instead of a cryptographically secure random source — this makes it guessable. - Reusing a verifier across login attempts instead of generating a fresh one per request.
- Sending the
code_challenge_methodasplainbecause a library defaults to it and nobody overrode it. - Storing the verifier in a cookie without
SecureandSameSiteattributes, which reopens exposure PKCE was meant to close. - Skipping the
stateparameter because “PKCE already protects us” — PKCE prevents code redemption by the wrong party,stateprevents CSRF; you need both.
POST /token HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&
code=SplxlOBeZQQYbYS6WxSbIA&
redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback&
client_id=s6BhdRkqt3&
code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
Takeaway
PKCE turns a static, guessable client secret into a fresh, per-request proof of possession. The client generates a random verifier, sends a hash of it up front, and only reveals the original value when redeeming the code — so an intercepted authorization code is useless without the verifier that never left the client until that final step. Use S256, generate the verifier with a secure random source, keep state around for CSRF protection, and treat PKCE as the default for every client type, not just the public ones.