code_verifier + code_challenge = BASE64URL(SHA256(code_verifier))plain instead of S256 for the code challenge method, or generating the verifier with a non-cryptographic random source.plain is insecure and prohibited by OAuth 2.1 [src4]state parameter for CSRF protection -- PKCE protects against code interception, not CSRF [src6]| Step | Action | Endpoint | Key Parameters |
|---|---|---|---|
| 1 | Generate code_verifier | Client-side | 43-128 chars, cryptographically random |
| 2 | Derive code_challenge | Client-side | BASE64URL(SHA256(code_verifier)) |
| 3 | Authorization request | GET /authorize | response_type=code, code_challenge, code_challenge_method=S256, state, redirect_uri |
| 4 | User authenticates | Authorization server | User enters credentials, consents |
| 5 | Receive authorization code | Redirect to client | code param in callback URL |
| 6 | Exchange code for tokens | POST /token | grant_type=authorization_code, code, code_verifier, redirect_uri |
| 7 | Server verifies PKCE | Authorization server | Computes BASE64URL(SHA256(code_verifier)), compares to stored challenge |
| 8 | Receive tokens | Client | access_token, refresh_token (optional), id_token (if OIDC) |
| 9 | Use access token | Resource server | Authorization: Bearer {access_token} |
| 10 | Refresh tokens | POST /token | grant_type=refresh_token, use refresh token rotation for SPAs |
START
├── Is the client a public client (SPA, mobile, desktop, CLI)?
│ ├── YES → Use Authorization Code + PKCE (this unit)
│ └── NO ↓
├── Is it server-to-server with no user interaction?
│ ├── YES → Use Client Credentials flow
│ └── NO ↓
├── Is it a device without a browser (smart TV, IoT)?
│ ├── YES → Use Device Authorization flow (RFC 8628)
│ └── NO ↓
├── Is it a server-side web app (confidential client)?
│ ├── YES → Use Authorization Code + PKCE + client_secret (OAuth 2.1 recommends PKCE for all)
│ └── NO ↓
└── DEFAULT → Use Authorization Code + PKCE (safest default for any client type)
Create a random string of 43-128 characters. Generate 32 random bytes and base64url-encode them (yielding 43 characters). [src1]
// Generate code_verifier (43 chars from 32 random bytes)
const array = new Uint8Array(32);
crypto.getRandomValues(array);
const codeVerifier = btoa(String.fromCharCode(...array))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
Verify: codeVerifier.length → should be 43, all characters match [A-Za-z0-9\-._~]
Hash the code_verifier with SHA-256 and base64url-encode the result. [src1]
// Derive code_challenge = BASE64URL(SHA256(code_verifier))
async function generateCodeChallenge(verifier) {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const digest = await crypto.subtle.digest('SHA-256', data);
return btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
const codeChallenge = await generateCodeChallenge(codeVerifier);
Verify: codeChallenge is a 43-character base64url string, different from codeVerifier
Construct the authorization request with PKCE parameters, state, and redirect URI. [src2]
// Build authorization URL
const state = crypto.randomUUID(); // CSRF protection
sessionStorage.setItem('oauth_state', state);
sessionStorage.setItem('code_verifier', codeVerifier);
const params = new URLSearchParams({
response_type: 'code',
client_id: 'YOUR_CLIENT_ID',
redirect_uri: 'https://app.example.com/callback',
scope: 'openid profile email',
state: state,
code_challenge: codeChallenge,
code_challenge_method: 'S256'
});
window.location.href = `https://auth.example.com/authorize?${params}`;
Verify: URL contains code_challenge and code_challenge_method=S256 parameters
When the authorization server redirects back, validate the state parameter and extract the code. [src2]
// In callback handler (e.g., /callback route)
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
const returnedState = urlParams.get('state');
// Validate state to prevent CSRF
const savedState = sessionStorage.getItem('oauth_state');
if (returnedState !== savedState) {
throw new Error('State mismatch -- possible CSRF attack');
}
Verify: code is present and returnedState === savedState
Send the code and the original code_verifier to the token endpoint. The server computes SHA256(code_verifier) and compares it to the stored code_challenge. [src1]
// Exchange code for tokens
const codeVerifier = sessionStorage.getItem('code_verifier');
const tokenResponse = await fetch('https://auth.example.com/oauth/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://app.example.com/callback',
code_verifier: codeVerifier
})
});
const tokens = await tokenResponse.json();
Verify: Response contains access_token. HTTP 200 status. No invalid_grant error.
Store tokens appropriately for your platform. For SPAs, use in-memory storage or httpOnly cookies -- never localStorage. [src6]
// Store tokens in memory (SPA best practice)
const tokenStore = {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresAt: Date.now() + (tokens.expires_in * 1000)
};
// Clean up PKCE artifacts
sessionStorage.removeItem('code_verifier');
sessionStorage.removeItem('oauth_state');
Verify: sessionStorage no longer contains code_verifier or oauth_state
// Input: Authorization server URLs + client_id
// Output: Complete PKCE flow utilities
class PKCEClient {
constructor(authUrl, tokenUrl, clientId, redirectUri) {
this.authUrl = authUrl;
this.tokenUrl = tokenUrl;
this.clientId = clientId;
this.redirectUri = redirectUri;
}
async generateVerifier() {
const bytes = crypto.getRandomValues(new Uint8Array(32));
return this.base64url(bytes);
}
async generateChallenge(verifier) {
const digest = await crypto.subtle.digest(
'SHA-256', new TextEncoder().encode(verifier)
);
return this.base64url(new Uint8Array(digest));
}
base64url(bytes) {
return btoa(String.fromCharCode(...bytes))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
}
# Input: auth_url, token_url, client_id
# Output: PKCE authorization + token exchange
import hashlib, base64, os, secrets, urllib.parse
import httpx # pip install httpx>=0.27
def generate_pkce_pair():
"""Generate code_verifier and code_challenge (S256)."""
verifier = base64.urlsafe_b64encode(os.urandom(32)).rstrip(b'=').decode()
challenge = base64.urlsafe_b64encode(
hashlib.sha256(verifier.encode()).digest()
).rstrip(b'=').decode()
return verifier, challenge
def build_auth_url(auth_url, client_id, redirect_uri, scope, challenge):
"""Build authorization URL with PKCE parameters."""
params = urllib.parse.urlencode({
'response_type': 'code',
'client_id': client_id,
'redirect_uri': redirect_uri,
'scope': scope,
'state': secrets.token_urlsafe(32),
'code_challenge': challenge,
'code_challenge_method': 'S256',
})
return f"{auth_url}?{params}"
async def exchange_code(token_url, client_id, code, verifier, redirect_uri):
"""Exchange authorization code for tokens using code_verifier."""
async with httpx.AsyncClient() as client:
resp = await client.post(token_url, data={
'grant_type': 'authorization_code',
'client_id': client_id,
'code': code,
'code_verifier': verifier,
'redirect_uri': redirect_uri,
})
resp.raise_for_status()
return resp.json()
// Input: Express request with ?code=...&state=...
// Output: Token response from authorization server
const crypto = require('node:crypto');
function generatePKCE() {
const verifier = crypto.randomBytes(32)
.toString('base64url'); // Node 16+
const challenge = crypto.createHash('sha256')
.update(verifier)
.digest('base64url');
return { verifier, challenge };
}
// In Express route handler:
app.get('/callback', async (req, res) => {
const { code, state } = req.query;
if (state !== req.session.oauthState) {
return res.status(403).send('State mismatch');
}
const resp = await fetch(TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: CLIENT_ID,
code,
code_verifier: req.session.codeVerifier,
redirect_uri: REDIRECT_URI,
}),
});
const tokens = await resp.json();
req.session.accessToken = tokens.access_token;
res.redirect('/dashboard');
});
plain code challenge method// BAD -- plain method sends code_verifier as code_challenge
// An attacker who intercepts the authorization request gets the verifier
const params = new URLSearchParams({
code_challenge: codeVerifier, // verifier sent in the clear
code_challenge_method: 'plain' // no hashing
});
// GOOD -- S256 hashes the verifier so intercepting the challenge is useless
const challenge = await generateCodeChallenge(codeVerifier);
const params = new URLSearchParams({
code_challenge: challenge, // SHA-256 hash, not the verifier
code_challenge_method: 'S256' // always S256
});
// BAD -- Math.random() is not cryptographically secure
// Attackers can predict the sequence
const verifier = Array.from({length: 43}, () =>
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
[Math.floor(Math.random() * 62)]
).join('');
// GOOD -- cryptographically secure random bytes
const bytes = crypto.getRandomValues(new Uint8Array(32));
const verifier = btoa(String.fromCharCode(...bytes))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
// BAD -- localStorage is accessible to any JS on the page (XSS attack vector)
localStorage.setItem('access_token', tokens.access_token);
localStorage.setItem('refresh_token', tokens.refresh_token);
// GOOD -- in-memory storage, cleared on page close
// For persistence, use httpOnly secure cookies set by backend
let tokenStore = { accessToken: null, refreshToken: null };
tokenStore.accessToken = tokens.access_token;
// Or: backend sets httpOnly cookie with SameSite=Strict
// BAD -- no state parameter means no CSRF protection
const params = new URLSearchParams({
response_type: 'code',
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
code_challenge: challenge,
code_challenge_method: 'S256'
// missing: state parameter
});
// GOOD -- state parameter prevents CSRF attacks
const state = crypto.randomUUID();
sessionStorage.setItem('oauth_state', state);
const params = new URLSearchParams({
response_type: 'code',
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
code_challenge: challenge,
code_challenge_method: 'S256',
state: state // verify this on callback
});
invalid_grant. Fix: Store verifier in sessionStorage (SPA) or server session before redirect. [src2]+, /, and = padding. PKCE requires base64url: + -> -, / -> _, strip =. Fix: Apply .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') after btoa(). [src1]/token may hit CORS errors. Fix: Ensure authorization server allows CORS from your app origin, or proxy through your backend. [src6]# Test code_verifier generation (Node.js)
node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
# Verify S256 challenge computation
echo -n "YOUR_CODE_VERIFIER" | openssl dgst -sha256 -binary | openssl base64 -A | tr '+/' '-_' | tr -d '='
# Test token endpoint manually with curl
curl -X POST https://auth.example.com/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code" \
-d "client_id=YOUR_CLIENT_ID" \
-d "code=AUTHORIZATION_CODE" \
-d "code_verifier=YOUR_CODE_VERIFIER" \
-d "redirect_uri=https://app.example.com/callback"
# Decode a JWT access token to inspect claims
echo "ACCESS_TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | python3 -m json.tool
# Check if authorization server supports PKCE (OpenID Discovery)
curl -s https://auth.example.com/.well-known/openid-configuration | \
python3 -c "import sys,json; d=json.load(sys.stdin); print('PKCE methods:', d.get('code_challenge_methods_supported', 'not advertised'))"
| Version | Status | Breaking Changes | Migration Notes |
|---|---|---|---|
| OAuth 2.1 (draft-14, 2024) | Draft / De facto standard | PKCE mandatory for ALL authorization code flows; Implicit flow removed | Add code_challenge + code_verifier to all existing auth code flows |
| RFC 9700 (2024) | BCP (Best Current Practice) | Recommends PKCE for all clients including confidential | Add PKCE even if you have a client_secret |
| RFC 7636 (2015) | Standard | Original PKCE specification | Baseline implementation; supports S256 and plain methods |
| OAuth 2.0 (RFC 6749, 2012) | Standard | PKCE not part of core spec | PKCE was an extension; Implicit flow was the recommended SPA approach |
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Building a SPA (React, Vue, Angular) | Server-to-server API calls (no user) | Client Credentials flow (RFC 6749 sec 4.4) |
| Building a mobile app (iOS, Android) | Device with no browser/keyboard | Device Authorization flow (RFC 8628) |
| Building a desktop app (Electron) | You only need user identity, not API access | OpenID Connect (but OIDC uses PKCE under the hood) |
| Building a server-side web app (OAuth 2.1 recommends it) | Issuing your own tokens (you are the auth server) | Build token endpoint with proper PKCE verification |
| Any new OAuth2 implementation (future-proofing for 2.1) | Simple API key authentication suffices | API keys with rate limiting |
state parameter), or token leakage from the client (use secure storage).code_challenge_methods_supported field in the provider's OpenID Discovery document.