OAuth has moved well past the one-size-fits-all standard it was initially set to be. Human and non-human identity rely on it for all sorts of critical auth requirements, but as OAuth has expanded beyond the constraints of server-side apps, a gap has emerged: how do public clients prove their identity without a stored secret?
That's where Proof Key for Code Exchange (PKCE, like 'pixie') comes in. PKCE is an extension to the authorization code flow where users authenticate via browser redirect.
The Coat Check Problem
Imagine you're at a conference and check your laptop bag. The standard procedure is you are given a claim ticket, you show the ticket and you get your bag.
What if someone is looking over your shoulder and sees the ticket number? This opens the opportunity to abuse this 'shared secret' and recite "ticket 47" to retrieve your laptop.
Now imagine a smarter system:
- When you check your bag, you make up a secret phrase "purple elephant dancing"
- You whisper a scrambled version of that phrase to the attendant (they can't reverse the scramble)
- They write down the scrambled version next to your bag
- Later, you come back and say the original phrase
- They scramble it themselves and check if it matches what they wrote down
Someone who overheard the scrambled version can't readily reverse-engineer "purple elephant dancing."
In a nutshell, that is PKCE. The secret phrase is your code_verifier. The scrambled version is the code_challenge. The attendant is the authorization server. The claim ticket is the authorization code. This is PKCE ensuring that even if someone intercepts your code, they can't use it.
Do You Need PKCE?
Short answer: yes, if you're using the authorization code flow.
| Client type | Classification | PKCE |
|---|---|---|
| Single-page app (SPA) | Public | Required - can't store secrets in browser |
| Mobile app | Public | Required - URL schemes can be hijacked, binaries decompiled |
| Desktop app | Public | Required - binaries can be decompiled |
| CLI tool | Public | Required - runs on user's machine |
| Server-side app | Confidential | Recommended - defense in depth against code injection |
It doesn't apply to:
- Client credentials - machine-to-machine, no authorization code
- Refresh token grants - no authorization code involved
- Implicit flow - deprecated, tokens returned directly without a code
- Device authorization - different mechanism, no redirect to intercept
If your flow has an authorization code, PKCE should protect it.
The OAuth 2.0 Security Best Current Practice now recommends PKCE for all OAuth clients, not just public ones. It adds defense in depth meaning even if you have a client secret, PKCE protects against authorization code injection attacks.
The Problem PKCE Solves
Traditional OAuth with a client secret was designed with server-side apps in mind, given the secret stays on your server, it isn't openly exposed during the authentication traffic.
The challenge comes when considering SPAs and mobile applications, an emergent modern trend. There's no secure place to store a secret when the code runs on an external device and anyone who intercepts the authorization code can then exchange it for tokens.
Proof Key for Code Exchange (PKCE) solves this by binding the token request to the original authorization request without requiring a stored secret.
The Core Mechanism
Going back to the coat check: instead of proving identity with a secret you store (the number 47 ticket), prove it by demonstrating you initiated the original request (purple elephant dancing).
-
Pre-auth request: Generate a random string (the
code_verifier) and keep it in memory -
In-auth request: Send a hash of that string (the
code_challenge) -
In-token request: Send the original
code_verifier - Server verification: Hash the verifier, compare to the stored challenge
An attacker who intercepts the authorization code doesn't have the original verifier and can't complete the token exchange.
The Actual Implementation
Step 1: Generate the Code Verifier
The verifier is a cryptographically random string, 43-128 characters, using only [A-Za-z0-9-._~]:
function generateCodeVerifier() {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return base64UrlEncode(array);
}
function base64UrlEncode(buffer) {
return btoa(String.fromCharCode(...buffer))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
Step 2: Derive the Code Challenge
The challenge is a SHA-256 hash of the verifier, base64url-encoded:
async function generateCodeChallenge(verifier) {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const hash = await crypto.subtle.digest('SHA-256', data);
return base64UrlEncode(new Uint8Array(hash));
}
An interesting note. Given SHA-256 always outputs 32 bytes, base64url of 32 bytes (256/6 = 42.67).
The challenge will always be 43 characters
Step 3: Authorization Request
Include the challenge (not the verifier) in your auth request:
GET /authorize?
response_type=code&
client_id=your-app&
redirect_uri=https://yourapp.com/callback&
code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&
code_challenge_method=S256&
state=xyz123&
scope=openid profile
The authorization server stores the challenge alongside your authorization session.
Step 4: Token Exchange
After the user authenticates and you receive the code, exchange it with the original verifier:
const response = await fetch('/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: authorizationCode,
redirect_uri: 'https://yourapp.com/callback',
client_id: 'your-app',
code_verifier: storedCodeVerifier // The original, not the hash
})
});
The server hashes the verifier you send and compares it to the stored challenge.
Match = tokens issued. No match = request rejected.
Common Misconfigurations
Using plain instead of S256: The spec technically allows sending the verifier unhashed as the challenge (code_challenge_method=plain). Don't. It exists only for clients that can't do SHA-256, which is essentially nothing modern.
Always use S256. If you can't, there are bigger problems.
Storing the verifier insecurely: The verifier should live in memory only. Don't put it in localStorage, sessionStorage, or cookies.
For SPAs, keep it in a JavaScript variable. For mobile, use secure memory.
Verifier too short: Must be 43-128 characters. Under 43 and the server should reject it.
Wrong base64url encoding: Standard base64 uses + and /. Base64url uses - and _. Mixing them up breaks everything. Also strip the = padding.
Seeing It In Action
Now reading about PKCE is one thing, but watching the actual challenge/verifier exchange happen is thrilling.
Run the full PKCE flow in ProtocolSoup's Looking Glass
Protocol Soup was built specifically for this - bring tactility and interactivity to authentication protocols and identity in general. Run the real flow against a real authorization server and inspect every parameter at each step.
The Looking Glass shows you the exact code_challenge sent in the auth request, then the code_verifier in the token request, so you can verify the cryptographic relationship yourself and compare against a traditional authorization code flow.
When PKCE Isn't Enough
Identity is an ever-growing frontier and PKCE was designed to protect the authorization code exchange.
It doesn't protect against:
- Token theft after issuance: Once you have tokens, PKCE's job is done. Store and transmit them securely.
- Compromised redirect URI: If an attacker controls the redirect URI, they get the code and can observe your token request. PKCE can't help here.
- XSS in your app: If attackers can run JavaScript in your app, they can steal the verifier from memory before you use it.
PKCE can be boiled down to one principle: prove you started what you're trying to finish.
For the authoritative source, see RFC 7636.
For current OAuth 2.0 Best Practice, see RFC 9700
Top comments (0)