- Published on
OAuth 2.0 With PKCE — Secure Authorization Code Flow for SPAs and Mobile Apps
- Authors

- Name
- Sanjeev Sharma
- @webcoderspeed1
Introduction
OAuth 2.0's authorization code flow is the gold standard for delegated authentication. PKCE (Proof Key for Public Clients) addresses the vulnerability in the original flow: public clients (browser and mobile apps) cannot securely store client secrets.
PKCE adds a cryptographic proof layer. The client creates a code verifier, hashes it into a code challenge, and proves possession of the original verifier when exchanging the authorization code for tokens. This prevents authorization code interception attacks.
Let's implement a complete production OAuth 2.0 + PKCE flow.
- PKCE Code Verifier and Challenge Generation
- Authorization Code Flow Step-by-Step
- State Parameter and CSRF Protection
- Token Storage: Memory vs HttpOnly Cookie
- Token Refresh and Rotation
- Scope Design and Permissions
- OAuth 2.1 Simplifications
- Common Implementation Mistakes
- Conclusion
PKCE Code Verifier and Challenge Generation
PKCE starts with generating a cryptographically secure code verifier and deriving its challenge.
// lib/pkce.ts
import crypto from 'crypto';
export interface PKCEPair {
codeVerifier: string;
codeChallenge: string;
codeChallengeMethod: 'S256';
}
export function generatePKCEPair(): PKCEPair {
// Code verifier: 43-128 URL-safe characters
const codeVerifier = crypto
.randomBytes(32)
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
// Code challenge: SHA256 hash of verifier (S256 method)
const codeChallenge = crypto
.createHash('sha256')
.update(codeVerifier)
.digest('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
return {
codeVerifier,
codeChallenge,
codeChallengeMethod: 'S256',
};
}
// Verify that verifier matches stored challenge
export function verifyCodeChallenge(
codeVerifier: string,
storedChallenge: string
): boolean {
const computedChallenge = crypto
.createHash('sha256')
.update(codeVerifier)
.digest('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
return computedChallenge === storedChallenge;
}
Authorization Code Flow Step-by-Step
Step 1: Generate PKCE pair and state, redirect to authorization endpoint.
// lib/oauth.ts
import { generatePKCEPair } from './pkce';
const OAUTH_PROVIDER = {
authorizationEndpoint: 'https://oauth.example.com/authorize',
tokenEndpoint: 'https://oauth.example.com/token',
userInfoEndpoint: 'https://oauth.example.com/userinfo',
};
export interface OAuthState {
codeVerifier: string;
state: string;
redirectUri: string;
createdAt: number;
}
export function generateAuthorizationURL(
clientId: string,
redirectUri: string,
scopes: string[]
): { authUrl: string; state: string; codeVerifier: string } {
const { codeVerifier, codeChallenge } = generatePKCEPair();
const state = crypto.randomBytes(16).toString('hex');
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
response_type: 'code',
scope: scopes.join(' '),
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
return {
authUrl: `${OAUTH_PROVIDER.authorizationEndpoint}?${params.toString()}`,
state,
codeVerifier,
};
}
Client-side redirect:
// pages/login.tsx
export default function LoginPage() {
async function handleOAuthLogin() {
const { authUrl, state, codeVerifier } = generateAuthorizationURL(
process.env.NEXT_PUBLIC_OAUTH_CLIENT_ID!,
`${window.location.origin}/api/oauth/callback`,
['openid', 'profile', 'email']
);
// Store state and verifier in sessionStorage (not localStorage for security)
sessionStorage.setItem('oauth_state', state);
sessionStorage.setItem('oauth_code_verifier', codeVerifier);
window.location.href = authUrl;
}
return <button onClick={handleOAuthLogin}>Sign in with OAuth</button>;
}
State Parameter and CSRF Protection
The state parameter prevents CSRF attacks where a malicious site tricks your browser into authorizing their app.
// pages/api/oauth/callback.ts
import { NextApiRequest, NextApiResponse } from 'next';
import crypto from 'crypto';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const { code, state, error } = req.query as Record<string, string>;
if (error) {
return res.status(400).json({ error: 'OAuth error: ' + error });
}
// CSRF check: verify state matches what we issued
const storedState = req.cookies.oauth_state;
if (state !== storedState) {
return res.status(401).json({ error: 'State mismatch - possible CSRF' });
}
// Retrieve code verifier (sent via secure cookie, not query param)
const codeVerifier = req.cookies.oauth_code_verifier;
if (!codeVerifier) {
return res.status(400).json({ error: 'Missing code verifier' });
}
try {
// Exchange code for tokens
const response = await fetch(OAUTH_PROVIDER.tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: process.env.OAUTH_CLIENT_ID!,
client_secret: process.env.OAUTH_CLIENT_SECRET!, // Back-end only
code,
redirect_uri: `${process.env.NEXT_PUBLIC_APP_URL}/api/oauth/callback`,
code_verifier: codeVerifier, // PKCE: prove we own the verifier
}).toString(),
});
if (!response.ok) {
throw new Error(`Token exchange failed: ${response.statusText}`);
}
const tokens = await response.json();
// Clear sensitive cookies
res.setHeader('Set-Cookie', [
`oauth_state=; Path=/; HttpOnly; Secure; Max-Age=0`,
`oauth_code_verifier=; Path=/; HttpOnly; Secure; Max-Age=0`,
]);
// Store tokens securely (see below)
await storeTokens(tokens);
return res.redirect(302, '/dashboard');
} catch (err) {
return res.status(500).json({ error: 'Token exchange failed' });
}
}
Token Storage: Memory vs HttpOnly Cookie
Never store access tokens in localStorage—it's vulnerable to XSS. Use secure httpOnly cookies or in-memory storage with refresh token rotation.
// lib/tokenStorage.ts
import { cookies } from 'next/headers';
export interface Tokens {
accessToken: string;
refreshToken?: string;
idToken?: string;
expiresIn: number;
}
const TOKEN_COOKIE_OPTIONS = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
path: '/',
};
// Strategy 1: HttpOnly cookies (server-side retrieval)
export async function storeTokensInCookie(tokens: Tokens) {
const cookieStore = await cookies();
cookieStore.set(
'accessToken',
tokens.accessToken,
{
...TOKEN_COOKIE_OPTIONS,
maxAge: tokens.expiresIn,
}
);
if (tokens.refreshToken) {
cookieStore.set(
'refreshToken',
tokens.refreshToken,
{
...TOKEN_COOKIE_OPTIONS,
maxAge: 30 * 24 * 60 * 60, // 30 days
}
);
}
if (tokens.idToken) {
// ID token (not sensitive; can be read by JS)
cookieStore.set(
'idToken',
tokens.idToken,
{
httpOnly: false,
secure: true,
sameSite: 'lax',
path: '/',
maxAge: tokens.expiresIn,
}
);
}
}
// Strategy 2: In-memory + refresh rotation (SPA approach)
let inMemoryAccessToken: string | null = null;
let inMemoryTokenExpiry: number | null = null;
export async function storeTokensInMemory(tokens: Tokens) {
inMemoryAccessToken = tokens.accessToken;
inMemoryTokenExpiry = Date.now() + tokens.expiresIn * 1000;
// Refresh token stored in httpOnly cookie only
const cookieStore = await cookies();
if (tokens.refreshToken) {
cookieStore.set('refreshToken', tokens.refreshToken, {
...TOKEN_COOKIE_OPTIONS,
maxAge: 30 * 24 * 60 * 60,
});
}
}
export async function getAccessToken(): Promise<string | null> {
if (!inMemoryAccessToken) return null;
// Check if expired; refresh if needed
if (inMemoryTokenExpiry && Date.now() > inMemoryTokenExpiry - 60000) {
// 1 minute before actual expiry, refresh
await refreshAccessToken();
}
return inMemoryAccessToken;
}
Token Refresh and Rotation
Implement refresh token rotation to detect and prevent token theft.
// lib/tokenRefresh.ts
export async function refreshAccessToken(): Promise<Tokens | null> {
const cookieStore = await cookies();
const refreshToken = cookieStore.get('refreshToken')?.value;
if (!refreshToken) {
return null; // No refresh token; user must re-authenticate
}
try {
const response = await fetch(OAUTH_PROVIDER.tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
client_id: process.env.OAUTH_CLIENT_ID!,
client_secret: process.env.OAUTH_CLIENT_SECRET!,
refresh_token: refreshToken,
}).toString(),
});
if (!response.ok) {
// Refresh token expired or invalid; user must re-authenticate
cookieStore.delete('refreshToken');
return null;
}
const newTokens = (await response.json()) as Tokens;
// Rotation: old refresh token becomes invalid
await storeTokensInCookie(newTokens);
return newTokens;
} catch (err) {
console.error('Token refresh failed:', err);
return null;
}
}
Scope Design and Permissions
Design scopes to follow the principle of least privilege.
// OAuth scope constants
export const OAUTH_SCOPES = {
PROFILE: 'profile', // Name, picture
EMAIL: 'email',
OPENID: 'openid', // ID token
OFFLINE: 'offline_access', // Refresh token
CUSTOM_API: 'api:read api:write', // Custom API scopes
};
// Scope groups for different use cases
export const SCOPE_GROUPS = {
READ_ONLY: [OAUTH_SCOPES.OPENID, OAUTH_SCOPES.PROFILE],
READ_WRITE: [
OAUTH_SCOPES.OPENID,
OAUTH_SCOPES.EMAIL,
OAUTH_SCOPES.CUSTOM_API,
],
OFFLINE_ACCESS: [
OAUTH_SCOPES.OPENID,
OAUTH_SCOPES.OFFLINE,
OAUTH_SCOPES.CUSTOM_API,
],
};
// Request scopes based on feature
export function getRequiredScopes(featureSet: 'basic' | 'premium'): string[] {
if (featureSet === 'basic') {
return SCOPE_GROUPS.READ_ONLY;
}
return SCOPE_GROUPS.READ_WRITE;
}
OAuth 2.1 Simplifications
OAuth 2.1 spec (draft) removes insecure flows and mandates best practices:
// OAuth 2.1 compliant implementation checklist:
// ✓ PKCE required for all clients (not just public)
// ✓ Implicit flow removed (no token in URL)
// ✓ Resource owner password flow removed
// ✓ Redirect URI must be exact match (no wildcard)
// ✓ State parameter required
// ✓ Bearer token expiry required
// ✓ HTTPS required
const OAuth21Config = {
requirePKCE: true, // Always
allowedResponseTypes: ['code'], // Only code, not 'token' or 'id_token'
redirectUriMustBeExactMatch: true,
requireState: true,
requireHttps: true,
tokenExpiryMax: 3600, // 1 hour for access tokens
};
Common Implementation Mistakes
Avoid these pitfalls:
// ❌ WRONG: Storing access token in localStorage
localStorage.setItem('accessToken', token); // XSS vulnerability!
// ✓ RIGHT: httpOnly cookie (automatic sending with requests)
res.setHeader(
'Set-Cookie',
`accessToken=${token}; HttpOnly; Secure; SameSite=Lax`
);
// ❌ WRONG: No CSRF protection
const code = req.query.code;
await exchangeCodeForToken(code); // What if attacker sent this code?
// ✓ RIGHT: Verify state parameter
if (req.query.state !== req.cookies.oauthState) {
throw new Error('CSRF detected');
}
// ❌ WRONG: Sending client secret from browser
fetch('/api/token', {
body: JSON.stringify({
client_id: PUBLIC_ID,
client_secret: SECRET, // EXPOSED!
}),
});
// ✓ RIGHT: Token exchange happens only on backend
// Frontend sends code to /api/oauth/callback
// Backend uses client_secret securely
// ❌ WRONG: No token refresh; user logged out after 1 hour
const accessToken = tokens.accessToken; // Fixed, will expire
// ✓ RIGHT: Auto-refresh on expiry
const token = await getAccessToken(); // Checks expiry, refreshes if needed
Conclusion
PKCE transformed OAuth 2.0 from a spec vulnerable to public client attacks into a secure, production-grade authorization framework. Implement it correctly: generate proper PKCE pairs, verify state parameters, store tokens securely in httpOnly cookies, rotate refresh tokens, and follow OAuth 2.1 simplifications.
Your users deserve secure authentication. Build it with PKCE.