PKCE Flow
Authorization Code flow for apps that cannot keep a secret.
- Who's involved
- User, app, auth server
- Best for
- Single-page and mobile apps
- Difficulty
- Moderate
1Overview
How it works
PKCE (Proof Key for Code Exchange, pronounced "pixy") extends the Authorization Code flow for public clients: apps that cannot safely store a client secret, such as single-page, mobile and desktop apps. Anyone can pull a secret out of such an app's code, so a fixed secret would protect nothing.
Instead, the app makes a fresh secret for every sign-in:
- It creates a random
code_verifier. - It sends a hash of it, the
code_challenge, when authorization starts. - It shows the original verifier when it exchanges the code for tokens.
This proves that the same app that started the request is finishing it. OAuth 2.1 recommends PKCE for all clients, even those with a secret.
2Use cases
When would you use this?
A single-page app
A React app in the browser lets a user sign in with Google. There is no server to hide a secret on.
A mobile app
A fitness app on your phone asks to read your data from another service.
A desktop app
A note-taking app on your laptop signs you in through your browser.
Not the right fit? Have a server that can keep a secret? Authorization Code works, and PKCE is still worth adding as an extra layer.
3Interactive
Try it step by step
See it step by step
Step 1 of 6
The app makes a secret and a fingerprint
It invents a random secret (the code_verifier) and works out a fingerprint of it (the code_challenge). The fingerprint is safe to share; the secret is not.
PKCE or Authorization Code?
| Authorization Code | PKCE | |
|---|---|---|
| Client type | Confidential (can keep secrets) | Public (cannot keep secrets) |
| Typical use | Server-side web apps | Single-page, mobile and desktop apps |
| Stolen code is useless because | The attacker lacks the client_secret | The attacker lacks the code_verifier |
4Practical advice
Do this, avoid that
Good practices
- Always use the
S256method โ It uses SHA-256. Theplainmethod is only for legacy systems - Generate verifiers with a secure random source โ Use
crypto.getRandomValues(), neverMath.random() - Keep the verifier in session storage โ Not
localStorage, and clear it after the token exchange - Rotate refresh tokens โ For single-page apps, each refresh token should work only once
- Consider a backend-for-frontend (BFF) โ A small server that holds tokens for your app and gives the browser
httpOnlycookies - Still check the
stateparameter โ PKCE does not replace CSRF protection
Common mistakes
- Storing tokens in
localStorageโ Any script on the page can read it (an XSS attack); use memory orhttpOnlycookies - Using weak random numbers โ
Math.random()is not cryptographically secure - Reusing a
code_verifierโ Create a new one for every authorization request - Using the
plainchallenge method โ UseS256(SHA-256) so the verifier stays hidden - Forgetting to validate
redirect_uriโ The authorization server must check that it matches the registered value
5Code
Code example
Show the code
// PKCE Flow Implementation for Single-Page Applications
// This flow is perfect for apps that cannot securely store a client_secret
// Step 1: Generate code verifier and challenge
function generateCodeVerifier() {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return base64URLEncode(array);
}
function base64URLEncode(buffer) {
return btoa(String.fromCharCode.apply(null, new Uint8Array(buffer)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
async function generateCodeChallenge(verifier) {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const hash = await crypto.subtle.digest('SHA-256', data);
return base64URLEncode(hash);
}
// Step 2: Initiate OAuth flow with PKCE
async function initiateOAuthFlowWithPKCE() {
// Generate and store verifier
const codeVerifier = generateCodeVerifier();
sessionStorage.setItem('code_verifier', codeVerifier);
// Generate challenge
const codeChallenge = await generateCodeChallenge(codeVerifier);
// Build authorization URL
const authUrl = 'https://auth-server.com/oauth/authorize';
const params = new URLSearchParams({
response_type: 'code',
client_id: 'your-public-client-id', // No secret needed!
redirect_uri: 'https://yourapp.com/callback',
scope: 'read write',
state: generateRandomState(),
code_challenge: codeChallenge,
code_challenge_method: 'S256' // SHA-256
});
// Redirect user
window.location.href = `${authUrl}?${params}`;
}
// Step 3: Handle callback and exchange code for tokens
async function handleOAuthCallback(authCode, state) {
// Verify state
if (state !== sessionStorage.getItem('oauth_state')) {
throw new Error('State mismatch - possible CSRF attack!');
}
// Retrieve stored verifier
const codeVerifier = sessionStorage.getItem('code_verifier');
if (!codeVerifier) {
throw new Error('Code verifier not found');
}
// Exchange code for tokens
const tokenUrl = 'https://auth-server.com/oauth/token';
const response = await fetch(tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code: authCode,
redirect_uri: 'https://yourapp.com/callback',
client_id: 'your-public-client-id',
code_verifier: codeVerifier // The secret revealed!
})
});
if (!response.ok) {
throw new Error(`Token exchange failed: ${response.status}`);
}
const tokens = await response.json();
// tokens = { access_token, refresh_token, expires_in, token_type }
// Clean up
sessionStorage.removeItem('code_verifier');
sessionStorage.removeItem('oauth_state');
return tokens;
}
// Step 4: Secure token storage (use httpOnly cookies on your backend proxy)
// For pure SPA, use memory storage with refresh token rotation
class TokenStore {
constructor() {
this.accessToken = null;
this.refreshToken = null;
}
setTokens(accessToken, refreshToken) {
this.accessToken = accessToken;
this.refreshToken = refreshToken;
// DO NOT store in localStorage (XSS vulnerable)
}
async getAccessToken() {
// Check if token is expired, refresh if needed
if (this.isTokenExpired()) {
await this.refreshAccessToken();
}
return this.accessToken;
}
async refreshAccessToken() {
const response = await fetch('https://auth-server.com/oauth/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: this.refreshToken,
client_id: 'your-public-client-id'
})
});
const tokens = await response.json();
this.setTokens(tokens.access_token, tokens.refresh_token);
}
}
// Helper functions
function generateRandomState() {
const array = new Uint8Array(16);
crypto.getRandomValues(array);
const state = base64URLEncode(array);
sessionStorage.setItem('oauth_state', state);
return state;
}