Implement OAuth 2.0 PKCE for Single-Page Applications
How to implement the OAuth 2.0 PKCE flow in single-page applications to securely authenticate users without exposing client secrets
The Proof Key for Code Exchange (PKCE) extension to OAuth 2.0 allows public clients like single-page applications to perform the authorization code flow without a client secret. It prevents authorization code interception attacks by binding the authorization request to the subsequent token exchange.
When to Use This
- You are building a SPA that authenticates against an OAuth 2.0 or OpenID Connect provider
- The application runs in a browser where a client secret cannot be kept confidential
- You want to prevent authorization code interception by malicious applications
Prerequisites
- An OAuth 2.0 provider that supports PKCE (Auth0, Okta, Google, Keycloak, etc.)
- A registered OAuth application with
http://localhost:3000as a redirect URI
Solution
1. Generate PKCE Parameters
// auth/pkce.ts
import { randomBytes, createHash } from 'crypto';
export function generatePKCE() {
const codeVerifier = base64URLEncode(randomBytes(32));
const codeChallenge = base64URLEncode(
createHash('sha256').update(codeVerifier).digest()
);
return { codeVerifier, codeChallenge };
}
function base64URLEncode(buffer: Buffer): string {
return buffer
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
2. Redirect to Authorization Endpoint
// auth/authorize.ts
export function buildAuthorizationUrl(params: {
authorizationEndpoint: string;
clientId: string;
redirectUri: string;
scope: string;
state: string;
codeChallenge: string;
}) {
const url = new URL(params.authorizationEndpoint);
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', params.clientId);
url.searchParams.set('redirect_uri', params.redirectUri);
url.searchParams.set('scope', params.scope);
url.searchParams.set('state', params.state);
url.searchParams.set('code_challenge', params.codeChallenge);
url.searchParams.set('code_challenge_method', 'S256');
return url.toString();
}
// Usage
const { codeVerifier, codeChallenge } = generatePKCE();
sessionStorage.setItem('pkce_verifier', codeVerifier);
const state = generateState();
sessionStorage.setItem('oauth_state', state);
window.location.href = buildAuthorizationUrl({
authorizationEndpoint: 'https://auth.example.com/oauth/authorize',
clientId: 'your-client-id',
redirectUri: 'http://localhost:3000/callback',
scope: 'openid profile email',
state,
codeChallenge,
});
3. Exchange Code for Tokens
// auth/tokenExchange.ts
export async function exchangeCodeForToken(params: {
tokenEndpoint: string;
clientId: string;
redirectUri: string;
code: string;
codeVerifier: string;
}) {
const response = await fetch(params.tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: params.clientId,
redirect_uri: params.redirectUri,
code: params.code,
code_verifier: params.codeVerifier,
}),
});
if (!response.ok) {
throw new Error(`Token exchange failed: ${response.statusText}`);
}
return response.json() as Promise<{
access_token: string;
refresh_token: string;
id_token: string;
expires_in: number;
}>;
}
// In callback handler
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
const state = urlParams.get('state');
if (state !== sessionStorage.getItem('oauth_state')) {
throw new Error('Invalid state parameter');
}
const codeVerifier = sessionStorage.getItem('pkce_verifier')!;
const tokens = await exchangeCodeForToken({
tokenEndpoint: 'https://auth.example.com/oauth/token',
clientId: 'your-client-id',
redirectUri: 'http://localhost:3000/callback',
code: code!,
codeVerifier,
});
4. Secure Token Storage
// auth/storage.ts
export function storeTokens(tokens: TokenResponse) {
// Store access token in memory only (most secure for SPAs)
window.__ACCESS_TOKEN__ = tokens.access_token;
// Store refresh token in httpOnly cookie via backend proxy
// Never store refresh tokens in localStorage
}
export function getAccessToken(): string | undefined {
return window.__ACCESS_TOKEN__;
}
How It Works
- Code Verifier is a random secret generated by the client
- Code Challenge is the SHA-256 hash of the verifier, sent with the authorization request
- Authorization Server stores the challenge and issues an authorization code
- Token Exchange requires the original verifier, proving the client initiated the flow
- Without PKCE, an intercepted authorization code could be exchanged by an attacker
Production Considerations
- Always validate the state parameter to prevent CSRF attacks
- Use Content Security Policy headers to mitigate XSS token theft
- Implement silent token refresh using
prompt=nonein a hidden iframe - Rotate refresh tokens and detect reuse to prevent token replay attacks
Common Mistakes
- Storing tokens in
localStoragewhere XSS can easily steal them - Not validating the state parameter during callback handling
- Using
response_type=token(implicit flow) which is deprecated for SPAs
Advanced Solutions
Silent token refresh with hidden iframe
Refresh tokens without a full redirect by using prompt=none in a hidden iframe. This works when the user still has an active session with the authorization server:
// auth/silentRefresh.ts
export function silentRefresh(config: {
authorizationEndpoint: string;
clientId: string;
redirectUri: string;
scope: string;
}): Promise<TokenResponse> {
return new Promise((resolve, reject) => {
const state = generateRandomString(32);
const iframe = document.createElement('iframe');
iframe.style.display = 'none';
iframe.src = buildAuthorizationUrl({
...config,
state,
codeChallenge: '', // Not needed for silent refresh
prompt: 'none',
response_type: 'code',
});
let cleanup: () => void;
const timeout = setTimeout(() => {
cleanup();
reject(new Error('Silent refresh timed out'));
}, 5000);
const messageHandler = (event: MessageEvent) => {
if (event.origin !== new URL(config.authorizationEndpoint).origin) return;
if (event.data.state !== state) return;
cleanup();
if (event.data.error) {
reject(new Error(event.data.error));
} else {
resolve(event.data as TokenResponse);
}
};
cleanup = () => {
clearTimeout(timeout);
window.removeEventListener('message', messageHandler);
iframe.remove();
};
window.addEventListener('message', messageHandler);
document.body.appendChild(iframe);
});
}
// Usage: call before token expires
async function refreshIfNeeded() {
const token = getAccessToken();
if (!token || isTokenExpired(token)) {
try {
const tokens = await silentRefresh({
authorizationEndpoint: 'https://auth.example.com/oauth/authorize',
clientId: 'your-client-id',
redirectUri: 'http://localhost:3000/callback',
scope: 'openid profile email',
});
storeTokens(tokens);
} catch (err) {
// Session expired, redirect to login
window.location.href = '/login';
}
}
}
function isTokenExpired(token: string): boolean {
const payload = JSON.parse(atob(token.split('.')[1]));
return Date.now() >= payload.exp * 1000 - 30_000; // 30s buffer
}
Backend-for-frontend (BFF) pattern
Instead of handling OAuth entirely in the browser, use a lightweight backend that holds the tokens in httpOnly cookies. The SPA only interacts with the backend:
// server/bff-auth.ts (Express backend)
import express from 'express';
import crypto from 'crypto';
import cookieParser from 'cookie-parser';
const app = express();
app.use(cookieParser());
const CLIENT_ID = process.env.OAUTH_CLIENT_ID!;
const CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET!;
const TOKEN_ENDPOINT = 'https://auth.example.com/oauth/token';
const AUTHORIZATION_ENDPOINT = 'https://auth.example.com/oauth/authorize';
const REDIRECT_URI = 'https://app.example.com/auth/callback';
app.get('/auth/login', (req, res) => {
const codeVerifier = base64URLEncode(crypto.randomBytes(32));
const codeChallenge = base64URLEncode(
crypto.createHash('sha256').update(codeVerifier).digest()
);
const state = base64URLEncode(crypto.randomBytes(16));
res.cookie('pkce_verifier', codeVerifier, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 600_000, // 10 minutes
});
res.cookie('oauth_state', state, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 600_000,
});
const url = new URL(AUTHORIZATION_ENDPOINT);
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', CLIENT_ID);
url.searchParams.set('redirect_uri', REDIRECT_URI);
url.searchParams.set('scope', 'openid profile email');
url.searchParams.set('state', state);
url.searchParams.set('code_challenge', codeChallenge);
url.searchParams.set('code_challenge_method', 'S256');
res.redirect(url.toString());
});
app.get('/auth/callback', async (req, res) => {
const { code, state } = req.query;
const expectedState = req.cookies.oauth_state;
const codeVerifier = req.cookies.pkce_verifier;
if (!code || state !== expectedState) {
return res.status(400).send('Invalid state or missing code');
}
const tokenResponse = await fetch(TOKEN_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
redirect_uri: REDIRECT_URI,
code: code as string,
code_verifier: codeVerifier,
}),
});
const tokens = await tokenResponse.json();
// Store tokens in httpOnly cookies — never exposed to JS
res.cookie('access_token', tokens.access_token, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: tokens.expires_in * 1000,
});
res.cookie('refresh_token', tokens.refresh_token, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
});
res.clearCookie('pkce_verifier');
res.clearCookie('oauth_state');
res.redirect('/');
});
app.get('/auth/token', (req, res) => {
const accessToken = req.cookies.access_token;
if (!accessToken) {
return res.status(401).json({ error: 'Not authenticated' });
}
// Return token to SPA for API calls
res.json({ access_token: accessToken });
});
app.post('/auth/refresh', async (req, res) => {
const refreshToken = req.cookies.refresh_token;
if (!refreshToken) {
return res.status(401).json({ error: 'No refresh token' });
}
const tokenResponse = await fetch(TOKEN_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
refresh_token: refreshToken,
}),
});
const tokens = await tokenResponse.json();
res.cookie('access_token', tokens.access_token, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: tokens.expires_in * 1000,
});
if (tokens.refresh_token) {
res.cookie('refresh_token', tokens.refresh_token, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 30 * 24 * 60 * 60 * 1000,
});
}
res.json({ success: true });
});
app.post('/auth/logout', async (req, res) => {
const refreshToken = req.cookies.refresh_token;
if (refreshToken) {
// Revoke token at auth server
await fetch(TOKEN_ENDPOINT + '/revoke', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
token: refreshToken,
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
}),
});
}
res.clearCookie('access_token');
res.clearCookie('refresh_token');
res.json({ success: true });
});
React hook for OAuth state management
// hooks/useAuth.ts
import { useState, useEffect, useCallback } from 'react';
interface AuthState {
isAuthenticated: boolean;
accessToken: string | null;
loading: boolean;
error: string | null;
}
export function useAuth() {
const [state, setState] = useState<AuthState>({
isAuthenticated: false,
accessToken: null,
loading: true,
error: null,
});
const login = useCallback(() => {
window.location.href = '/auth/login';
}, []);
const logout = useCallback(async () => {
await fetch('/auth/logout', { method: 'POST' });
setState({
isAuthenticated: false,
accessToken: null,
loading: false,
error: null,
});
window.location.href = '/';
}, []);
const refreshToken = useCallback(async () => {
try {
const res = await fetch('/auth/refresh', { method: 'POST' });
if (!res.ok) throw new Error('Refresh failed');
const data = await res.json();
if (data.success) {
const tokenRes = await fetch('/auth/token');
const tokenData = await tokenRes.json();
setState({
isAuthenticated: true,
accessToken: tokenData.access_token,
loading: false,
error: null,
});
}
} catch {
setState({
isAuthenticated: false,
accessToken: null,
loading: false,
error: 'Session expired',
});
}
}, []);
useEffect(() => {
async function init() {
try {
const res = await fetch('/auth/token');
if (res.ok) {
const data = await res.json();
setState({
isAuthenticated: true,
accessToken: data.access_token,
loading: false,
error: null,
});
} else {
setState({
isAuthenticated: false,
accessToken: null,
loading: false,
error: null,
});
}
} catch {
setState({
isAuthenticated: false,
accessToken: null,
loading: false,
error: 'Failed to check auth status',
});
}
}
init();
// Set up automatic refresh before token expires
const refreshInterval = setInterval(() => {
refreshToken();
}, 10 * 60 * 1000); // Every 10 minutes
return () => clearInterval(refreshInterval);
}, [refreshToken]);
return { ...state, login, logout, refreshToken };
}
// Usage in component
function App() {
const { isAuthenticated, loading, login, logout } = useAuth();
if (loading) return <div>Loading...</div>;
if (!isAuthenticated) {
return <button onClick={login}>Sign in</button>;
}
return (
<div>
<h1>Dashboard</h1>
<button onClick={logout}>Sign out</button>
</div>
);
} Frequently Asked Questions
Is PKCE required for all SPAs?
Yes. The OAuth 2.0 Security Best Current Practice recommends PKCE for all OAuth clients, including confidential ones.
Can I use PKCE with a backend that handles the token exchange?
Yes. This is actually more secure. The backend stores the refresh token in an httpOnly cookie while the SPA only receives a short-lived access token.
What if the provider does not support PKCE?
Use a backend-for-frontend (BFF) pattern where your backend handles the OAuth flow and the SPA authenticates via session cookies.
Related Resources
Security Headers
Harden web applications with HTTP security headers: CSP, HSTS, X-Frame-Options, and a thorough security header checklist.
PatternAdapter Pattern for Integrating External REST APIs
Use the Adapter pattern to normalize responses from external REST APIs into a consistent internal model without leaking third-party formats into your domain
GuideSecurity Best Practices Guide
A thorough guide to application security: authentication, authorization, input validation, secrets management, and common vulnerability prevention.
RecipePassword Hashing in Production
Securely hash and verify passwords using bcrypt, scrypt, and Argon2 with what works.
RecipeNode.js OAuth2 GitHub Login with Express
Implement GitHub OAuth2 login flow in Node.js with Express and Passport
GuideOAuth2 and OIDC: Authorization Code, PKCE, Token Validation
Master OAuth2 and OpenID Connect for production: authorization code flow with PKCE, token validation, refresh tokens, scopes, JWT verification, and security best practices.