Authorization Code Flow with PKCE
Overview
The Authorization Code Flow with PKCE (Proof Key for Code Exchange) is the recommended OAuth 2.0 flow for public clients like Single Page Applications (SPAs) and mobile apps. It provides the security of the authorization code flow without exposing the client secret.
Why PKCE?
Traditional authorization code flow requires a client secret, which cannot be safely stored in public clients. PKCE adds an additional layer of security by:
- Preventing authorization code interception attacks
- Enabling secure authentication for SPAs and mobile apps
- No client secret required
Flow Steps
1. Generate Code Verifier
Generate a random string (43-128 characters) that will be used to verify the authorization request:
function generateCodeVerifier() {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return base64URLEncode(Buffer.from(array));
}
// Output: d29yZHMyMm1hbnl0b2tlbnRoYXRAZGlnaXRhbC1ob3N0LmNvbQ2. Generate Code Challenge
Create a hash of the code verifier using SHA-256 and encode it:
import { createHash } from 'crypto';
function generateCodeChallenge(verifier) {
const hash = createHash('sha256').update(verifier).digest();
return base64URLEncode(hash);
}
// Output: E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM3. Build Authorization URL
Construct the authorization URL with PKCE parameters:
const authUrl = new URL('https://sso-nutri.ricethailand.go.th/realms/myrealm/protocol/openid-connect/auth');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('client_id', 'your-client-id');
authUrl.searchParams.set('redirect_uri', 'http://localhost:3000/callback');
authUrl.searchParams.set('scope', 'openid profile email');
authUrl.searchParams.set('state', generateRandomState());
authUrl.searchParams.set('code_challenge', codeChallenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
// Redirect user to authUrl
window.location.href = authUrl.toString();4. User Authorizes
The user is redirected to the authorization server and logs in.
5. Handle Callback
After successful authorization, the user is redirected back with an authorization code:
// URL: http://localhost:3000/callback?code=AUTHORIZATION_CODE&state=random_state
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
const state = urlParams.get('state');
// Verify state to prevent CSRF6. Exchange Code for Tokens
Exchange the authorization code for tokens using the original code verifier:
async function exchangeCodeForTokens(code, codeVerifier) {
const response = await fetch('https://sso-nutri.ricethailand.go.th/realms/myrealm/protocol/openid-connect/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: 'http://localhost:3000/callback',
code_verifier: codeVerifier
})
});
return response.json();
// Returns: { access_token, refresh_token, id_token, token_type, expires_in }
}Complete Implementation Example
import { createHash } from 'crypto';
function base64URLEncode(buffer) {
return buffer.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
function generateCodeVerifier() {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return base64URLEncode(Buffer.from(array));
}
function generateCodeChallenge(verifier) {
return base64URLEncode(
createHash('sha256').update(verifier).digest()
);
}
async function authorizeWithPKCE() {
// Step 1 & 2: Generate PKCE parameters
const codeVerifier = generateCodeVerifier();
const codeChallenge = generateCodeChallenge(codeVerifier);
// Store codeVerifier for later (sessionStorage or memory)
sessionStorage.setItem('codeVerifier', codeVerifier);
// Step 3: Build authorization URL
const authUrl = new URL('https://sso-nutri.ricethailand.go.th/realms/myrealm/protocol/openid-connect/auth');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('client_id', 'your-client-id');
authUrl.searchParams.set('redirect_uri', 'http://localhost:3000/callback');
authUrl.searchParams.set('scope', 'openid profile email');
authUrl.searchParams.set('state', Math.random().toString(36).substring(2));
authUrl.searchParams.set('code_challenge', codeChallenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
// Redirect to authorization server
window.location.href = authUrl.toString();
}
async function handleCallback() {
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
const codeVerifier = sessionStorage.getItem('codeVerifier');
if (code && codeVerifier) {
// Step 6: Exchange code for tokens
const tokens = await exchangeCodeForTokens(code, codeVerifier);
// Store tokens securely (HTTP-only cookies recommended)
// For SPAs, use SameSite=Strict cookies set by the server
// or in-memory storage for short-lived tokens
document.cookie = 'access_token=' + tokens.access_token + '; SameSite=Strict; Secure';
// Clear code verifier
sessionStorage.removeItem('codeVerifier');
}
}Security Best Practices
- Store code verifier in memory - Never store in localStorage, use sessionStorage for codeVerifier only
- Store tokens in HTTP-only cookies - Prefer server-set cookies over JavaScript storage
- Verify state parameter - Always validate the state to prevent CSRF attacks
- Use HTTPS - All redirects must use HTTPS in production
- Generate cryptographically secure random values - Use crypto.getRandomValues() or similar
- Use S256 challenge method - Prefer SHA-256 over plain text
When to Use PKCE
- Single Page Applications (SPAs)
- Mobile Applications
- Desktop Applications
- Any public client without a secure storage for client secrets