OAuth 2.0 PKCE: Building the Authorization Code Flow the Right Way in Node.js
The implicit grant is deprecated. The authorization code flow with PKCE is the only secure way to authenticate users in SPAs, mobile apps, and server-side integrations. Here is the complete Node.js implementation with code verifiers, token exchange, refresh token rotation, and the three mistakes that still leak tokens.
Your SPA redirects users to the OAuth provider, gets back an access token in the URL fragment, and stores it in localStorage. This is the implicit grant flow, and it has been deprecated since 2019 for a reason: the access token is exposed in the browser URL, there is no client authentication, and the token cannot be rotated without user reauthentication. But when you try to switch to the standard authorization code flow, you hit a wall. The code flow requires a client_secret to exchange the code for tokens, and you cannot safely store a secret in the browser.
PKCE (Proof Key for Code Exchange, pronounced “pixie”) solves this without a client secret. Instead of a static shared secret, the client generates a one-time cryptographic challenge, sends the hashed version during the authorization request, and presents the original verifier during the token exchange. An attacker who intercepts the authorization code cannot exchange it without the verifier, which never leaves the client.
This post builds the complete OAuth 2.0 PKCE flow in Node.js, covering both the client side (the Express/Fastify routes that initiate the flow and handle the callback) and the server side (the token endpoint that validates the challenge and issues tokens). You will end with a production-ready implementation that handles code exchange, refresh token rotation, and the edge cases that leak tokens in production.
How PKCE changes the authorization code flow
The standard authorization code flow (RFC 6749) works like this:
- The client redirects the user to the authorization server with a
client_idandredirect_uri. - The user authenticates and authorizes the application.
- The authorization server redirects back to the client with an authorization code in the URL.
- The client sends the code plus its
client_secretto the token endpoint. - The authorization server verifies the secret and returns an access token (and optionally a refresh token).
Step 4 is the problem for SPAs and mobile apps. There is no safe place to store a client_secret. Anyone who inspects the bundle or decompiles the app finds it, and then they can exchange any authorization code for a token.
PKCE (RFC 7636) replaces the client_secret with a dynamically generated pair of values:
code_verifier: a high-entropy random string (43-128 characters from a restricted character set).code_challenge: a transformation of the verifier, either the plain verifier (S256is the hash,plainis the raw value; always useS256).
The flow becomes:
- The client generates
code_verifierandcode_challenge. - The client redirects the user with the
code_challengeandcode_challenge_methodadded to the authorization request. - The authorization server stores the challenge alongside the authorization code.
- The user authenticates and is redirected back with the code.
- The client sends the code plus the original
code_verifierto the token endpoint. - The authorization server hashes the verifier using the stored method and compares it to the stored challenge. If they match, it issues tokens.
An attacker who intercepts the authorization code at step 3 cannot exchange it because they do not have the code_verifier. The verifier was generated on the client, sent directly to the authorization server at step 5, and never transmitted anywhere else.
Building the client-side flow in Express
The client initiates the PKCE flow by generating the verifier and challenge, then redirecting the user to the authorization server.
import crypto from 'node:crypto';
import express from 'express';
const router = express.Router();
// Configuration (from the authorization server's developer portal)
const OAUTH_CONFIG = {
authorizationEndpoint: 'https://auth.example.com/authorize',
tokenEndpoint: 'https://auth.example.com/token',
clientId: process.env.OAUTH_CLIENT_ID!,
redirectUri: 'https://app.example.com/auth/callback',
scope: 'openid profile email',
};
// Generate a cryptographically random code verifier
function generateCodeVerifier(): string {
// 32 bytes of random data, base64url-encoded = 43 characters
// RFC 7636 requires 43-128 characters from [A-Za-z0-9-._~]
const buffer = crypto.randomBytes(32);
return base64URLEncode(buffer);
}
// Base64URL-encode (no padding, no +/=, no /)
function base64URLEncode(buffer: Buffer): string {
return buffer
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
// SHA-256 hash, base64url-encoded = S256 code challenge
async function generateCodeChallenge(verifier: string): Promise<string> {
const hash = crypto.createHash('sha256').update(verifier).digest();
return base64URLEncode(hash);
}
// Step 1: Initiate the PKCE flow
router.get('/auth/login', async (req, res) => {
// Generate the verifier and challenge
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
// Store the verifier in the session so we can retrieve it
// when the callback arrives. This is critical.
req.session.codeVerifier = codeVerifier;
// Build the authorization URL with PKCE parameters
const params = new URLSearchParams({
response_type: 'code',
client_id: OAUTH_CONFIG.clientId,
redirect_uri: OAUTH_CONFIG.redirectUri,
scope: OAUTH_CONFIG.scope,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
state: crypto.randomUUID(), // Anti-CSRF: verified on callback
});
req.session.oauthState = params.get('state')!;
// Redirect the user to the authorization server
res.redirect(`${OAUTH_CONFIG.authorizationEndpoint}?${params.toString()}`);
});
Three things to get right in this step:
The state parameter. This is a random value included in the authorization request and verified when the callback arrives. It prevents CSRF attacks on the redirect URI. Without it, an attacker can trick your application into processing an authorization code they obtained for their own session. Generate it fresh for every request and verify it matches on callback.
Storing the verifier. The code_verifier must be retrievable when the callback arrives. A server-side session (Express session with a cookie-backed store) is the safest option. Do not store it in the browser, do not pass it through the redirect URL, do not put it in a cookie that the browser can read.
Using S256, not plain. The code_challenge_method parameter tells the authorization server how to verify the challenge on token exchange. plain sends the verifier in the authorization request itself, which defeats the purpose of PKCE. Always use S256, which sends the SHA-256 hash.
Handling the callback (the token exchange)
After the user authenticates at the authorization server, they are redirected back to your redirect_uri with an authorization code and the state parameter.
// Step 2: Handle the OAuth callback
router.get('/auth/callback', async (req, res) => {
const { code, state, error } = req.query as Record<string, string>;
// Handle authorization errors from the provider
if (error) {
console.error('Authorization error:', error);
return res.redirect('/login?error=authorization_denied');
}
// Verify the state parameter to prevent CSRF
if (state !== req.session.oauthState) {
console.error('State mismatch: possible CSRF attack');
req.session.destroy(() => {});
return res.status(403).redirect('/login?error=invalid_state');
}
// Retrieve the stored verifier
const codeVerifier = req.session.codeVerifier;
if (!codeVerifier) {
console.error('No code verifier found in session');
req.session.destroy(() => {});
return res.status(400).redirect('/login?error=missing_verifier');
}
try {
// Exchange the authorization code for tokens
const tokens = await exchangeCodeForTokens(code, codeVerifier);
// Store tokens securely (see below)
await storeTokens(req, tokens);
// Clean up session artifacts
delete req.session.codeVerifier;
delete req.session.oauthState;
// Redirect to the application
res.redirect('/dashboard');
} catch (err) {
console.error('Token exchange failed:', err);
res.status(500).redirect('/login?error=token_exchange_failed');
}
});
// Exchange the authorization code for access and refresh tokens
async function exchangeCodeForTokens(
code: string,
codeVerifier: string
): Promise<TokenResponse> {
const body = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: OAUTH_CONFIG.redirectUri,
client_id: OAUTH_CONFIG.clientId,
code_verifier: codeVerifier,
});
const response = await fetch(OAUTH_CONFIG.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
},
body: body.toString(),
});
if (!response.ok) {
const errorBody = await response.text();
throw new Error(
`Token exchange failed: ${response.status} ${errorBody}`
);
}
return response.json() as Promise<TokenResponse>;
}
interface TokenResponse {
access_token: string;
token_type: string;
expires_in: number;
refresh_token?: string;
id_token?: string;
scope?: string;
}
The token exchange is a server-to-server call. The code_verifier is sent directly from your server to the authorization server over TLS. An attacker who intercepted the authorization code from the redirect URL cannot exchange it because they do not have the verifier.
After this step, you have an access token (short-lived, used for API calls) and optionally a refresh token (long-lived, used to get new access tokens when they expire).
Storing tokens securely on the server
Where you store the tokens depends on what kind of client you are building.
For a server-side rendered application (Express with sessions), store the tokens in the session store. Use a Redis-backed session store, not the default in-memory store, and encrypt the token payload before writing it to Redis. The access token is in-memory for the duration of the request, and the session ID is the only thing in the browser cookie.
import session from 'express-session';
import RedisStore from 'connect-redis';
import { createClient } from 'redis';
const redisClient = createClient({ url: process.env.REDIS_URL! });
await redisClient.connect();
app.use(
session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET!, // 32+ random bytes
name: 'session_id', // not 'connect.sid'
cookie: {
httpOnly: true, // No JavaScript access
secure: true, // HTTPS only
sameSite: 'lax', // CSRF protection
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
},
resave: false,
saveUninitialized: false,
})
);
For an SPA (the browser handles the redirect), you have a harder problem. The token arrives on the callback URL and must be passed to the SPA. The safest approach is to have the server receive the callback, exchange the code for tokens, set an httpOnly session cookie, and return the page. The SPA never sees the raw token.
Never store access tokens in localStorage when you can avoid it. An httpOnly session cookie is immune to XSS exfiltration. If you must use localStorage (for example, because you are building a mobile app or a CLI tool), the PKCE verifier protects the initial code exchange, but the stored token is still vulnerable to the runtime environment.
Refreshing tokens with PKCE
Access tokens expire (typically in 15-60 minutes). The authorization code flow also returns a refresh_token that can be used to get new access tokens without user interaction. The refresh token grant does not use PKCE (the refresh token itself is the credential), but it should be protected by refresh token rotation.
async function refreshAccessToken(
refreshToken: string,
clientId: string
): Promise<TokenResponse> {
const body = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: clientId,
});
const response = await fetch(OAUTH_CONFIG.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
},
body: body.toString(),
});
if (!response.ok) {
throw new Error(`Token refresh failed: ${response.status}`);
}
return response.json() as Promise<TokenResponse>;
}
The token response from a refresh grant includes a new access_token and, critically, a new refresh_token. This is refresh token rotation: every time you use a refresh token, the authorization server issues a new one and invalidates the old one. If a refresh token is ever used twice (because an attacker stole it and both you and the attacker try to use it), the authorization server detects the reuse and invalidates the entire token family.
Implement reuse detection on your end by storing a counter or a last-used timestamp alongside the refresh token in your database:
async function rotateRefreshToken(
userId: string,
currentRefreshToken: string,
newRefreshToken: string
): Promise<void> {
// In a transaction:
// 1. Read the stored refresh token hash for this user
// 2. If the stored hash matches the current token hash, update it
// 3. If the stored hash does not match, the token was compromised
const stored = await db.refreshTokens.findUnique({ where: { userId } });
if (!stored) {
// No stored token, this might be the first rotation
await db.refreshTokens.create({
data: {
userId,
tokenHash: hashToken(newRefreshToken),
family: crypto.randomUUID(),
createdAt: new Date(),
rotatedAt: new Date(),
},
});
return;
}
if (stored.tokenHash !== hashToken(currentRefreshToken)) {
// Token reuse detected. Someone else used this refresh token.
// Invalidate the entire token family.
console.error(`Refresh token reuse detected for user ${userId}`);
await db.refreshTokens.deleteMany({ where: { family: stored.family } });
// Force the user to re-authenticate
throw new Error('refresh_token_reuse_detected');
}
await db.refreshTokens.update({
where: { userId },
data: {
tokenHash: hashToken(newRefreshToken),
rotatedAt: new Date(),
},
});
}
function hashToken(token: string): string {
return crypto.createHash('sha256').update(token).digest('hex');
}
What can go wrong in production
Teams that implement PKCE for the first time make the same mistakes. Here are the three that cause the most damage in production.
Mistake 1: Storing the verifier in the redirect URL
Some tutorials suggest passing the code_verifier through the redirect URL as a query parameter so the server can retrieve it. This defeats PKCE. If an attacker can read the redirect URL (browser history, server logs, Referer header), they have the verifier and can exchange the intercepted authorization code for tokens.
// WRONG: verifier in the redirect URL
const callbackUrl = `https://app.example.com/auth/callback?verifier=${codeVerifier}`;
// Now any attacker who can read URLs has the verifier.
The verifier must be stored server-side in the session. The session cookie is the only client-side artifact.
Mistake 2: Forgetting the state parameter
The state parameter is not optional. Without it, an attacker can craft a URL like this:
https://app.example.com/auth/callback?code=ATTACKER_CODE&state=...
If your application does not verify that state matches what it generated, the attacker can force your application to exchange their authorization code for tokens, linking their OAuth account to your user’s session. This is a classic CSRF attack on the OAuth callback.
The fix is a single line in the callback handler:
if (state !== req.session.oauthState) {
// Reject the callback
}
Mistake 3: Not validating the token response signature
If the authorization server returns a JWT access token or an ID token, validate its signature on every callback. A malformed token response or an MITM attacker could inject a forged token, and without signature validation, your application will accept it.
import jwt from 'jsonwebtoken';
import jwksClient from 'jwks-rsa';
const client = jwksClient({
jwksUri: 'https://auth.example.com/.well-known/jwks.json',
});
async function verifyToken(token: string): Promise<jwt.JwtPayload> {
const getKey = (header: jwt.JwtHeader, callback: jwt.SigningKeyCallback) => {
client.getSigningKey(header.kid, (err, key) => {
if (err) return callback(err);
callback(null, key!.getPublicKey());
});
};
return new Promise((resolve, reject) => {
jwt.verify(token, getKey, {
algorithms: ['RS256'],
issuer: 'https://auth.example.com/',
audience: OAUTH_CONFIG.clientId,
}, (err, payload) => {
if (err) reject(err);
else resolve(payload as jwt.JwtPayload);
});
});
}
Use RS256 (asymmetric signing) instead of HS256 (symmetric shared secret). With HS256, any client that knows the secret can forge tokens. With RS256, the authorization server signs with a private key and clients verify with a public key, which is safe to distribute via the JWKS endpoint.
Building your own PKCE authorization server in Node.js
Sometimes you are not the client. You are the authorization server, and your partners or your own SPAs need to authenticate through your service. Here is the minimal PKCE-compliant token endpoint in Express:
import crypto from 'node:crypto';
interface AuthorizationCode {
clientId: string;
redirectUri: string;
codeChallenge: string;
codeChallengeMethod: string;
userId: string;
expiresAt: Date;
}
// In-memory store (use Redis in production)
const authCodes = new Map<string, AuthorizationCode>();
const refreshTokens = new Map<string, { userId: string; family: string }>();
// Authorization endpoint - validates PKCE params and issues code
router.post('/authorize', (req, res) => {
// Validate the user is authenticated (session, redirect to login, etc.)
const { client_id, redirect_uri, code_challenge, code_challenge_method, state } = req.body;
if (code_challenge_method !== 'S256') {
return res.status(400).json({ error: 'invalid_request',
error_description: 'Only S256 code_challenge_method is supported' });
}
if (!code_challenge || code_challenge.length < 43) {
return res.status(400).json({ error: 'invalid_request',
error_description: 'Invalid code_challenge' });
}
// Generate the authorization code
const code = crypto.randomUUID();
authCodes.set(code, {
clientId: client_id,
redirectUri: redirect_uri,
codeChallenge: code_challenge,
codeChallengeMethod: code_challenge_method,
userId: req.session.userId,
expiresAt: new Date(Date.now() + 10 * 60 * 1000), // 10 minutes
});
// Redirect back to the client
const redirectUrl = new URL(redirect_uri);
redirectUrl.searchParams.set('code', code);
redirectUrl.searchParams.set('state', state);
res.redirect(redirectUrl.toString());
});
// Token endpoint - validates the code_verifier against the stored challenge
router.post('/token', async (req, res) => {
const { grant_type, code, code_verifier, redirect_uri, client_id } = req.body;
if (grant_type === 'authorization_code') {
const stored = authCodes.get(code);
if (!stored) {
return res.status(400).json({ error: 'invalid_grant',
error_description: 'Invalid authorization code' });
}
if (stored.expiresAt < new Date()) {
authCodes.delete(code);
return res.status(400).json({ error: 'invalid_grant',
error_description: 'Authorization code expired' });
}
if (stored.redirectUri !== redirect_uri) {
return res.status(400).json({ error: 'invalid_grant',
error_description: 'redirect_uri mismatch' });
}
// Validate the code_verifier
const expectedChallenge = base64URLEncode(
crypto.createHash('sha256').update(code_verifier).digest()
);
if (expectedChallenge !== stored.codeChallenge) {
return res.status(400).json({ error: 'invalid_grant',
error_description: 'code_verifier does not match code_challenge' });
}
// Code is valid and verifier matches. Issue tokens.
authCodes.delete(code);
const accessToken = await signAccessToken(stored.userId, stored.clientId);
const refreshToken = crypto.randomUUID();
refreshTokens.set(refreshToken, {
userId: stored.userId,
family: crypto.randomUUID(),
});
return res.json({
access_token: accessToken,
token_type: 'Bearer',
expires_in: 3600,
refresh_token: refreshToken,
});
}
if (grant_type === 'refresh_token') {
// Validate and rotate the refresh token
const stored = refreshTokens.get(req.body.refresh_token);
if (!stored) {
return res.status(400).json({ error: 'invalid_grant' });
}
// Rotate: issue new refresh token, invalidate old one
refreshTokens.delete(req.body.refresh_token);
const newRefreshToken = crypto.randomUUID();
refreshTokens.set(newRefreshToken, stored);
const accessToken = await signAccessToken(stored.userId, client_id);
return res.json({
access_token: accessToken,
token_type: 'Bearer',
expires_in: 3600,
refresh_token: newRefreshToken,
});
}
return res.status(400).json({ error: 'unsupported_grant_type' });
});
async function signAccessToken(userId: string, clientId: string): Promise<string> {
// Use RS256 with a key loaded from environment/config
// In production, load from a secure key management service
return jwt.sign(
{
sub: userId,
client_id: clientId,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 3600,
},
privateKey,
{ algorithm: 'RS256' }
);
}
This is a minimal implementation. A production authorization server needs:
- Rate limiting on the token endpoint (brute force attacks on authorization codes).
- Refresh token reuse detection (store a counter per token family).
- Client authentication (at minimum validate
client_idagainst a registered list). - Proper error responses per RFC 6749 (consistent error codes so clients can handle them programmatically).
The practical takeaway
If you are building an application that authenticates users through an OAuth provider, you should never implement the implicit grant flow. It has been deprecated for seven years and every browser vendor has been actively removing support for the fragment-based redirect patterns it relies on.
PKCE transforms the authorization code flow from a flow that requires a server-side confidential client into one that any client (SPA, mobile app, CLI tool, IoT device) can use securely. The difference is a dynamically generated secret that exists for exactly one authorization request and is never transmitted anywhere that an attacker can intercept it.
The three rules for getting PKCE right:
- Store the verifier server-side. The session cookie is the only client-side artifact. Never pass the verifier through a URL.
- Always include and verify the state parameter. It is the CSRF defense for the callback endpoint.
- Always validate the token signature. If the authorization server returns JWTs, verify them with the provider’s JWKS endpoint before trusting their contents.
The code in this post is the complete loop: client initiates the flow, server issues tokens with challenge validation, and both sides handle refresh with rotation. Copy the patterns, adapt them to your provider’s endpoints, and you have a secure auth flow that passes any security review.
A note from Yojji
Authentication architecture is one of those things that looks simple in a tutorial and gets complex fast in production: multiple providers, token versioning, refresh rotation across server restarts, and the session store scaling to millions of users. Yojji’s teams have built auth systems across fintech, healthcare, and SaaS products where a single auth flaw means a compliance incident.
Yojji is an international custom software development company with offices in Europe, the US, and the UK. They specialize in full-cycle software delivery across the JavaScript ecosystem (React, Node.js, TypeScript), cloud platforms, and microservices architectures. Their senior engineers handle the security-critical pieces like OAuth infrastructure so your team can focus on the product logic that differentiates you.