PKCE Implementation Guide
Secure authentication for browser-based and mobile applications using OAuth 2.0 with PKCE.
Table of Contents
Overview
How It Works
Implementation
Using Your Tokens
Security Best Practices
Common Pitfalls
Troubleshooting
Overview
PKCE (Proof Key for Code Exchange) is a security extension that protects OAuth 2.0 authorization codes from interception. It's required for all public clients — applications that can't securely store secrets, including SPAs, mobile apps, and native desktop apps.
Key Concepts
Term | Description |
|---|---|
Code Verifier | A random string (43-128 chars) your app generates and keeps secret |
Code Challenge | SHA-256 hash of the verifier, sent with the authorization request |
Authorization Code | Temporary code returned after user login, exchanged for tokens |
What You'll Receive
After successful authentication, you get three tokens:
Token | Purpose | Typical Lifetime |
|---|---|---|
Access Token | Call APIs and UserInfo endpoint | 1 hour |
ID Token | JWT containing user identity claims | 1 hour |
Refresh Token | Get new tokens without re-login | Days/weeks |
How It Works
┌─────────────┐ ┌──────────────┐
│ Your │ │Authorization │
│ App │ │ Server │
└──────┬──────┘ └──────┬───────┘
│ │
│ 1. Generate code_verifier + code_challenge │
│ │
│──── Authorization Request ──────────────────────>│
│ (code_challenge, state, scopes) │
│ │
│ User logs in │
│ │
│<─── Redirect with authorization code ────────────│
│ │
│──── Token Request ──────────────────────────────>│
│ (code + code_verifier) │
│ │
│ Server verifies: │
│ SHA256(verifier) == challenge? │
│ │
│<─── Access Token + ID Token + Refresh Token ─────│
│ │Why PKCE? Even if an attacker intercepts the authorization code, they can't exchange it for tokens without the original code verifier that only your app knows.
Implementation
Step 1: Generate PKCE Parameters
// Generate cryptographically random code verifier
function generateCodeVerifier() {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return base64UrlEncode(array);
}
// Create SHA-256 code challenge
async function generateCodeChallenge(verifier) {
const data = new TextEncoder().encode(verifier);
const hash = await crypto.subtle.digest('SHA-256', data);
return base64UrlEncode(new Uint8Array(hash));
}
// Generate random state for CSRF protection
function generateState() {
const array = new Uint8Array(16);
crypto.getRandomValues(array);
return base64UrlEncode(array);
}
// Base64URL encoding (URL-safe, no padding)
function base64UrlEncode(buffer) {
return btoa(String.fromCharCode(...buffer))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}Step 2: Start Authorization
async function login() {
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
const state = generateState();
// Store for callback verification
sessionStorage.setItem('code_verifier', codeVerifier);
sessionStorage.setItem('oauth_state', state);
const authUrl = new URL('https://your-issuer.com/authorize');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('client_id', 'your-client-id');
authUrl.searchParams.set('redirect_uri', 'https://yourapp.com/callback');
authUrl.searchParams.set('scope', 'openid profile email');
authUrl.searchParams.set('state', state);
authUrl.searchParams.set('code_challenge', codeChallenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
window.location.href = authUrl.toString();
}Available Scopes
Scope | Claims Returned |
|---|---|
|
|
|
|
|
|
|
|
Step 3: Handle Callback
async function handleCallback() {
const params = new URLSearchParams(window.location.search);
// Check for errors
if (params.get('error')) {
throw new Error(params.get('error_description') || params.get('error'));
}
// Validate state (CSRF protection)
if (params.get('state') !== sessionStorage.getItem('oauth_state')) {
throw new Error('State mismatch - possible CSRF attack');
}
// Exchange code for tokens
const tokens = await exchangeCode(
params.get('code'),
sessionStorage.getItem('code_verifier')
);
// Clean up
sessionStorage.removeItem('code_verifier');
sessionStorage.removeItem('oauth_state');
return tokens;
}Step 4: Exchange Code for Tokens
async function exchangeCode(code, codeVerifier) {
const response = await fetch('https://your-issuer.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: 'your-client-id',
code: code,
redirect_uri: 'https://yourapp.com/callback',
code_verifier: codeVerifier,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error_description || error.error);
}
return response.json();
}Token Response
{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"id_token": "eyJhbGciOiJSUzI1NiIs...",
"refresh_token": "v1.refresh.abc123...",
"token_type": "Bearer",
"expires_in": 3600
}Using Your Tokens
Get User Profile
async function getUserInfo(accessToken) {
const response = await fetch('https://your-issuer.com/userinfo', {
headers: { 'Authorization': `Bearer ${accessToken}` }
});
return response.json();
}
// Response example:
// {
// "sub": "user_abc123",
// "email": "user@example.com",
// "email_verified": true,
// "name": "Alice Johnson"
// }Decode ID Token (Quick Access)
function decodeIdToken(idToken) {
const payload = idToken.split('.')[1];
return JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
}
const user = decodeIdToken(tokens.id_token);
console.log('Welcome,', user.name);Logout
function logout(idToken) {
// Clear local storage
localStorage.removeItem('tokens');
// Redirect to end session
const logoutUrl = new URL('https://your-issuer.com/logout');
logoutUrl.searchParams.set('id_token_hint', idToken);
logoutUrl.searchParams.set('post_logout_redirect_uri', window.location.origin);
window.location.href = logoutUrl.toString();
}Security Best Practices
Token Storage
Storage | Security | Recommendation |
|---|---|---|
Memory (variable) | Best | High-security apps |
| Good | SPAs (cleared on tab close) |
| Moderate | Use with caution (XSS vulnerable) |
httpOnly Cookie | Best | Requires backend support |
Checklist
Always use HTTPS in production
Validate
stateparameter on every callbackUse
sessionStoragefor code verifier (notlocalStorage)Generate new code verifier for each login attempt
Never log tokens or include in error messages
Implement token refresh before expiration
Common Pitfalls
1. Wrong Base64 Encoding
// ❌ Wrong - standard base64
const challenge = btoa(hash);
// ✅ Correct - base64url (URL-safe, no padding)
const challenge = btoa(String.fromCharCode(...new Uint8Array(hash)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');2. Weak Random Generation
// ❌ Wrong - predictable
const verifier = Math.random().toString(36);
// ✅ Correct - cryptographically secure
const array = new Uint8Array(32);
crypto.getRandomValues(array);3. Skipping State Verification
// ❌ Wrong - vulnerable to CSRF
const code = params.get('code');
exchangeCode(code, verifier);
// ✅ Correct - verify state first
if (params.get('state') !== savedState) {
throw new Error('State mismatch');
}
exchangeCode(code, verifier);4. Mismatched Code Verifier
// ❌ Wrong - different verifiers
const challenge = generateChallenge(verifier1);
exchangeCode(code, verifier2); // Won't work!
// ✅ Correct - store and reuse same verifier
sessionStorage.setItem('code_verifier', verifier);
// ... after callback ...
const storedVerifier = sessionStorage.getItem('code_verifier');Troubleshooting
Error | Cause | Solution |
|---|---|---|
| Code verifier mismatch or code expired | Verify same verifier used; codes expire in ~2 min |
| Server config issue | Ensure |
| Missing parameter | Check all required params in auth URL |
State mismatch | Multiple tabs or session cleared | Use |
Validate Your Implementation
async function testPKCE() {
// RFC 7636 test vector
const testVerifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk';
const expectedChallenge = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM';
const challenge = await generateCodeChallenge(testVerifier);
console.assert(challenge === expectedChallenge, 'PKCE implementation is correct');
}Quick Reference
// Complete minimal implementation
const auth = {
async login() {
const v = generateCodeVerifier(), s = generateState();
sessionStorage.setItem('cv', v);
sessionStorage.setItem('st', s);
location.href = `https://your-issuer.com/authorize?` +
`response_type=code&client_id=YOUR_ID&redirect_uri=${encodeURIComponent(location.origin + '/callback')}` +
`&scope=openid%20profile%20email&state=${s}&code_challenge=${await generateCodeChallenge(v)}&code_challenge_method=S256`;
},
async callback() {
const p = new URLSearchParams(location.search);
if (p.get('state') !== sessionStorage.getItem('st')) throw new Error('State mismatch');
const r = await fetch('https://your-issuer.com/token', {
method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({grant_type: 'authorization_code', client_id: 'YOUR_ID',
code: p.get('code'), redirect_uri: location.origin + '/callback',
code_verifier: sessionStorage.getItem('cv')})
});
sessionStorage.removeItem('cv'); sessionStorage.removeItem('st');
return r.json();
}
};