PKCE: Proof Key for Code Exchange
How a public client proves the authorization code is really its own, without holding any secret.
code_challenge = base64url(sha256(code_verifier))A confidential client protects the token exchange with its client secret. A public client — an SPA, a mobile app — has no secret it can keep. PKCE (RFC 7636, pronounced "pixie") fills that gap with a per-request secret invented on the spot.
How it works
- Before redirecting, the client generates a random code_verifier (43–128 characters).
- It derives code_challenge = base64url(SHA256(verifier)) and sends only the challenge to
/authorize. - The authorization server stores the challenge with the issued code.
- At
/token, the client sends the original verifier. - The server hashes it and compares. No match, no tokens.
What it stops
The authorization code interception attack. On mobile, several apps could register the same custom URL scheme (myapp://callback), so a malicious app could receive your code. Without PKCE it could redeem it. With PKCE it holds a code it cannot use — it never saw the verifier, and the challenge is a one-way hash.
The same reasoning applies in a browser: a code leaked through history, a referrer, or a logging proxy is useless on its own.
Always S256
code_challenge_method may be plain (challenge = verifier) or S256. plain defeats the purpose — anyone who intercepts the authorize request has the verifier. Use S256 and, if you run an authorization server, refuse plain.
Use it everywhere
OAuth 2.1 makes PKCE mandatory for all clients, confidential ones included. It costs two lines and adds a defence that does not depend on your client secret staying secret.
Example
# Generate a verifier and its challenge
verifier=$(openssl rand -base64 96 | tr -d '\n=+/' | cut -c1-64)
challenge=$(printf '%s' "$verifier" \
| openssl dgst -sha256 -binary \
| openssl base64 -A | tr '+/' '-_' | tr -d '=')
echo "verifier : $verifier" # kept secret by the client
echo "challenge: $challenge" # safe to put in a URL
# 1. Authorize — send only the challenge
https://auth.dfg.com/authorize?response_type=code&client_id=abc-spa
&redirect_uri=https://abc.com/callback
&code_challenge=$challenge&code_challenge_method=S256&state=...
# 2. Token — now prove you knew the verifier
curl -X POST https://auth.dfg.com/token \
-d grant_type=authorization_code -d code=$CODE \
-d client_id=abc-spa -d redirect_uri=https://abc.com/callback \
-d code_verifier=$verifierWhen to use it
- A mobile app protects its authorization code against another app that registered the same custom URL scheme on the device.
- An SPA completes the authorization code flow with no client secret at all, because PKCE provides the proof of possession instead.
- An authorization server refuses code_challenge_method=plain, closing the downgrade path that would make PKCE decorative.
More examples
PKCE in a browser client
The history.replaceState call matters: leaving ?code= in the address bar puts a used authorization code into browser history and any Referer sent from that page.
// Generate the pair with the Web Crypto API
function randomVerifier() {
const bytes = crypto.getRandomValues(new Uint8Array(32));
return btoa(String.fromCharCode(...bytes))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
async function challengeFor(verifier) {
const digest = await crypto.subtle.digest('SHA-256',
new TextEncoder().encode(verifier));
return btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
export async function startLogin() {
const verifier = randomVerifier();
const state = randomVerifier();
// sessionStorage is per-tab and cleared on close. Acceptable for a value that
// lives for seconds; a server-set HttpOnly cookie is stronger if you have a server.
sessionStorage.setItem('pkce_verifier', verifier);
sessionStorage.setItem('oauth_state', state);
const url = new URL('https://auth.dfg.com/authorize');
url.search = new URLSearchParams({
response_type: 'code',
client_id: 'abc-spa',
redirect_uri: 'https://abc.com/callback',
scope: 'openid profile orders:read',
state,
code_challenge: await challengeFor(verifier),
code_challenge_method: 'S256',
}).toString();
location.assign(url.toString());
}
export async function completeLogin() {
const params = new URLSearchParams(location.search);
const state = sessionStorage.getItem('oauth_state');
if (!state || params.get('state') !== state) throw new Error('invalid_state');
const verifier = sessionStorage.getItem('pkce_verifier');
sessionStorage.removeItem('pkce_verifier');
sessionStorage.removeItem('oauth_state');
const res = await fetch('https://auth.dfg.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: params.get('code'),
client_id: 'abc-spa', // no secret anywhere
redirect_uri: 'https://abc.com/callback',
code_verifier: verifier,
}),
});
if (!res.ok) throw new Error('token_exchange_failed');
history.replaceState({}, '', '/'); // strip the code from the URL bar
return res.json();
}Verifying PKCE if you run the authorization server
Revoking tokens already issued for a replayed code is the same reuse-detection idea as refresh rotation: a second use means a copy exists somewhere it should not.
import { createHash, timingSafeEqual } from 'crypto';
// /authorize — store the challenge WITH the code
await db.authCodes.insert({
code,
clientId,
redirectUri,
userId,
codeChallenge: req.query.code_challenge,
codeChallengeMethod: req.query.code_challenge_method,
expiresAt: new Date(Date.now() + 60_000), // 60 seconds
usedAt: null,
});
// /token — verify before issuing anything
app.post('/token', async (req, res) => {
const row = await db.authCodes.findByCode(req.body.code);
if (!row || row.expiresAt < new Date()) {
return res.status(400).json({ error: 'invalid_grant' });
}
// Single use: a replayed code means someone intercepted it → revoke the lot.
if (row.usedAt) {
await db.tokens.revokeIssuedFor(row.code);
return res.status(400).json({ error: 'invalid_grant' });
}
// redirect_uri must match the one used at /authorize, exactly.
if (row.redirectUri !== req.body.redirect_uri ||
row.clientId !== req.body.client_id) {
return res.status(400).json({ error: 'invalid_grant' });
}
if (row.codeChallenge) {
if (row.codeChallengeMethod !== 'S256') { // refuse 'plain'
return res.status(400).json({ error: 'invalid_request' });
}
const computed = createHash('sha256')
.update(req.body.code_verifier || '').digest('base64url');
const a = Buffer.from(computed), b = Buffer.from(row.codeChallenge);
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return res.status(400).json({ error: 'invalid_grant' });
}
}
await db.authCodes.markUsed(row.code);
res.json(await issueTokens(row.userId, row.clientId, row.scope));
});
Discussion