The PKCE Guardian
Shield Against the Dark Arts - Protector of Public Clients
"I am the PKCE Guardian! Where applications cannot keep secrets, I provide divine protection! With my cryptographic shield, even the vilest code interceptor cannot steal your authorization! Mobile apps, single-page applications, all who cannot hide a client_secret—come under my protection!"
Mortal Translation
PKCE (Proof Key for Code Exchange, pronounced "pixy") is an extension to the Authorization Code flow designed for public clients—applications that cannot securely store a client secret. This includes single-page applications (SPAs), mobile apps, and desktop applications.
Instead of relying on a client secret, PKCE uses a dynamically generated cryptographic challenge. The app creates a random code_verifier
, sends its hash (code_challenge
) during authorization, and later proves it has the original verifier when exchanging the code for tokens.
The Shield of PKCE
Generate code_verifier (random string) and code_challenge (SHA256 hash)
Divine Commentary: The shield is forged! A random secret and its cryptographic hash—this is my divine protection!
Redirect to Authorization Server with client_id, redirect_uri, code_challenge, code_challenge_method
Divine Commentary: I send the hash, but keep the original secret! No villain can reverse this divine encryption!
User authenticates and grants consent
Divine Commentary: The mortal proves their identity while I stand guard!
Returns authorization code (stores code_challenge)
Divine Commentary: The authorization server remembers my challenge! The code alone is worthless without the verifier!
Exchanges code + code_verifier for tokens
Divine Commentary: Now I reveal the original secret! The auth server verifies it matches the challenge!
Verifies SHA256(code_verifier) == code_challenge
Divine Commentary: The shield holds! Only the true application could possess the original verifier!
Returns access_token and refresh_token
Divine Commentary: Victory! The tokens are granted, and no attacker could intercept this sacred exchange!
⚡ Interactive Flow Simulator
Experience the Authorization Code flow step-by-step with visual animations
Flow Visualization
Generate PKCE Challenge
App creates random code_verifier and its SHA-256 hash (code_challenge)
🛡️ PKCE Protection: The code_verifier is kept secret in your app. Only the hash (code_challenge) is sent. This prevents authorization code interception attacks.
Redirect to Authorization Server
User Authenticates & Consents
Authorization Code Returned
Exchange Code with Verifier
Access Protected Resources
🛡️ Why The Guardian Was Summoned
The Problem: Mobile and single-page applications cannot securely store a client_secret. Any secret bundled in a mobile app or JavaScript can be extracted by examining the code or intercepting network traffic.
The Old Way: The Implicit Flow was used, but it passed access tokens through the browser URL—vulnerable to interception and has been deprecated.
The Divine Solution: PKCE allows these apps to use the more secure Authorization Code flow without needing a client_secret. The dynamically generated code_verifier acts as a one-time secret that an attacker cannot predict or steal.
The Divine Purpose
The Guardian speaks: "When Codeus demands a client_secret but your application cannot hide one, call upon ME! I replace the static secret with a dynamic cryptographic proof that changes with each authentication!"
In mortal terms: PKCE prevents authorization code interception attacks. Even if an attacker steals the authorization code, they cannot exchange it for tokens without the code_verifier, which only exists in your app's memory.
The Sacred Ritual
Step 1: Generate the Shield
Create a random code_verifier
(43-128 characters). Generate its SHA-256 hash as the code_challenge
. Store the verifier securely in memory or session storage.
Step 2: Begin Authorization
Redirect to the authorization server with the code_challenge (NOT the verifier). Include code_challenge_method=S256
to indicate SHA-256 hashing.
Step 3: User Grants Access
The user authenticates and consents. The auth server stores the code_challenge and returns an authorization code.
Step 4: Prove Possession
Exchange the authorization code for tokens, including the originalcode_verifier
. The auth server hashes it and verifies it matches the stored challenge.
Ancient Wisdom (Best Practices)
- ✓Always use S256 method - SHA-256 hashing; plain method is only for legacy systems
- ✓Generate cryptographically random verifiers - Use crypto.getRandomValues(), never Math.random()
- ✓Store verifier in session storage - Not localStorage; clear it after token exchange
- ✓Implement refresh token rotation - Refresh tokens should be single-use for SPAs
- ✓Use a backend-for-frontend (BFF) when possible - Proxy tokens through httpOnly cookies
- ✓Validate state parameter - PKCE doesn't replace CSRF protection
Forbidden Arts (Common Mistakes)
- ✗Storing tokens in localStorage - Vulnerable to XSS attacks; use memory or httpOnly cookies
- ✗Using weak random generation - Math.random() is NOT cryptographically secure
- ✗Reusing code_verifier - Generate a fresh one for each authorization flow
- ✗Using plain code challenge method - S256 (SHA-256) is required for security
- ✗Forgetting to validate redirect_uri - Auth server must validate this matches registration
The Mortal's Guide to Implementation
// 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;
}
When to Consult the PKCE Guardian
React, Vue, Angular apps that run entirely in the browser
iOS, Android, React Native apps that cannot secure a client_secret
Electron, native desktop apps distributed to users
Applications where code can be inspected or decompiled
OAuth 2.1 recommends PKCE for ALL clients, even those with secrets, for defense in depth
The Guardian vs. Codeus: When to Choose?
Aspect | Codeus (Auth Code) | PKCE Guardian |
---|---|---|
Client Type | Confidential (can keep secrets) | Public (cannot keep secrets) |
Use Cases | Server-side web apps | SPAs, mobile, desktop apps |
Requires client_secret | Yes | No |
Protection Method | Static client_secret | Dynamic code_verifier |
Code Interception Risk | Mitigated by client_secret | Mitigated by PKCE proof |
Consult Also With
⚡ Codeus - For traditional web applications with backend servers
👑 Machinus - For machine-to-machine communication without user involvement
Divine Decree: RFC 7636