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
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
Implement OAuth 2.0 PKCE for Single-Page Applications
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
FAQ
Q: Is PKCE required for all SPAs? A: Yes. The OAuth 2.0 Security Best Current Practice recommends PKCE for all OAuth clients, including confidential ones.
Q: Can I use PKCE with a backend that handles the token exchange? A: 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.
Q: What if the provider does not support PKCE? A: Use a backend-for-frontend (BFF) pattern where your backend handles the OAuth flow and the SPA authenticates via session cookies.