OAuth2 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.
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.
Introduction
OAuth2 is an authorization framework that lets users grant third-party applications access to their resources without sharing credentials. OpenID Connect (OIDC) adds an identity layer on top, providing authentication and user info. This guide walks through the authorization code flow with PKCE, token validation, refresh tokens, scopes, JWT verification, and production security patterns.
OAuth2 Roles
┌──────────┐ ┌──────────────┐ ┌──────────────┐
│ Client │────►│ Authorization │────►│ Resource │
│ (App) │ │ Server │ │ Owner (User) │
└──────────┘ └──────────────┘ └──────────────┘
│ │
│ ┌──────────────┐ │
└────────►│ Resource │◄──────────┘
│ Server (API)│
└──────────────┘
- Resource Owner: the user who owns the data
- Client: the application requesting access
- Authorization Server: issues tokens (e.g., Auth0, Keycloak)
- Resource Server: the API protecting user resources
Authorization Code Flow with PKCE
PKCE (Proof Key for Code Exchange) prevents authorization code interception. It is mandatory for SPAs and mobile apps.
Step 1: Generate PKCE verifier and challenge
// auth/pkce.ts — Generate PKCE pair
import crypto from 'crypto';
function base64UrlEncode(buffer: Buffer): string {
return buffer.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
export function generatePkcePair(): { verifier: string; challenge: string } {
const verifier = base64UrlEncode(crypto.randomBytes(32));
const challenge = base64UrlEncode(
crypto.createHash('sha256').update(verifier).digest(),
);
return { verifier, challenge };
}
Step 2: Redirect user to authorization endpoint
// auth/authorize.ts — Build authorization URL
export function buildAuthUrl(params: {
authorizationEndpoint: string;
clientId: string;
redirectUri: string;
scope: string;
state: string;
codeChallenge: string;
}): 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 { verifier, challenge } = generatePkcePair();
const state = crypto.randomUUID();
// Store verifier and state in session
session.pkceVerifier = verifier;
session.oauthState = state;
const authUrl = buildAuthUrl({
authorizationEndpoint: 'https://auth.example.com/authorize',
clientId: 'my-client-id',
redirectUri: 'https://app.example.com/callback',
scope: 'openid profile email read:orders',
state,
codeChallenge: challenge,
});
// Redirect user
res.redirect(authUrl);
Step 3: Handle callback and exchange code for tokens
// auth/callback.ts — Exchange authorization code for tokens
app.get('/callback', async (req, res) => {
const { code, state } = req.query;
// Validate state to prevent CSRF
if (state !== session.oauthState) {
return res.status(400).send('Invalid state parameter');
}
// Exchange code for tokens
const tokenResponse = await fetch('https://auth.example.com/oauth/token', {
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, // omit for public clients
code,
redirect_uri: 'https://app.example.com/callback',
code_verifier: session.pkceVerifier, // PKCE verifier from step 1
}),
});
const tokens = await tokenResponse.json();
// Store tokens
session.accessToken = tokens.access_token;
session.idToken = tokens.id_token;
session.refreshToken = tokens.refresh_token;
session.expiresAt = Date.now() + tokens.expires_in * 1000;
res.redirect('/dashboard');
});
Token Response
{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "dGhpcyBpcyBhIHJlZnJl...",
"id_token": "eyJhbGciOiJSUzI1NiIs...",
"scope": "openid profile email read:orders"
}
JWT Validation
Verify access token
// auth/jwt.ts — Validate JWT tokens
import jwt from 'jsonwebtoken';
import jwksClient from 'jwks-rsa';
const client = jwksClient({
jwksUri: 'https://auth.example.com/.well-known/jwks.json',
cache: true,
cacheMaxAge: 600000, // 10 minutes
});
function getKey(header: jwt.JwtHeader, callback: jwt.SigningKeyCallback) {
client.getSigningKey(header.kid, (err, key) => {
if (err) return callback(err);
callback(null, key.getPublicKey());
});
}
export function verifyAccessToken(token: string): Promise<JwtPayload> {
return new Promise((resolve, reject) => {
jwt.verify(token, getKey, {
algorithms: ['RS256'],
audience: 'my-api',
issuer: 'https://auth.example.com/',
}, (err, decoded) => {
if (err) return reject(err);
resolve(decoded as JwtPayload);
});
});
}
// Middleware
async function authMiddleware(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing bearer token' });
}
const token = authHeader.split(' ')[1];
try {
const payload = await verifyAccessToken(token);
// Check token expiration
if (payload.exp && payload.exp < Date.now() / 1000) {
return res.status(401).json({ error: 'Token expired' });
}
// Inject user context
req.user = {
id: payload.sub,
scopes: payload.scope?.split(' ') || [],
email: payload.email,
};
next();
} catch (error) {
return res.status(401).json({ error: 'Invalid token' });
}
}
Validate ID token (OIDC)
// auth/oidc.ts — Validate OIDC ID token
export function verifyIdToken(idToken: string, expectedNonce: string): OidcPayload {
const payload = jwt.verify(idToken, getKey, {
algorithms: ['RS256'],
audience: process.env.OAUTH_CLIENT_ID,
issuer: 'https://auth.example.com/',
}) as OidcPayload;
// Verify nonce to prevent replay attacks
if (payload.nonce !== expectedNonce) {
throw new Error('Nonce mismatch — possible replay attack');
}
// Verify required OIDC claims
if (!payload.sub) throw new Error('Missing sub claim');
if (!payload.iss) throw new Error('Missing iss claim');
if (!payload.aud) throw new Error('Missing aud claim');
if (!payload.exp) throw new Error('Missing exp claim');
if (!payload.iat) throw new Error('Missing iat claim');
return payload;
}
Refresh Token Flow
// auth/refresh.ts — Refresh expired access tokens
export async function refreshAccessToken(refreshToken: string): Promise<TokenResponse> {
const response = await fetch('https://auth.example.com/oauth/token', {
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,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(`Refresh failed: ${error.error_description}`);
}
return response.json();
}
// Auto-refresh middleware
async function ensureValidToken(req: Request, res: Response, next: NextFunction) {
if (Date.now() >= session.expiresAt - 60000) { // Refresh 60s before expiry
try {
const tokens = await refreshAccessToken(session.refreshToken);
session.accessToken = tokens.access_token;
session.expiresAt = Date.now() + tokens.expires_in * 1000;
if (tokens.refresh_token) {
session.refreshToken = tokens.refresh_token;
}
} catch (error) {
// Refresh failed — redirect to login
return res.redirect('/login');
}
}
next();
}
Scope-Based Authorization
// auth/scopes.ts — Check scopes for API endpoints
function requireScope(scope: string) {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.user?.scopes.includes(scope)) {
return res.status(403).json({
error: 'insufficient_scope',
required: scope,
granted: req.user?.scopes,
});
}
next();
};
}
// Usage
app.get('/api/orders', authMiddleware, requireScope('read:orders'), getOrders);
app.post('/api/orders', authMiddleware, requireScope('write:orders'), createOrder);
app.delete('/api/orders/:id', authMiddleware, requireScope('delete:orders'), deleteOrder);
OIDC Discovery
// auth/discovery.ts — Fetch OIDC endpoints from discovery document
export async function getOidcConfig(issuer: string): Promise<OidcConfig> {
const response = await fetch(`${issuer}/.well-known/openid-configuration`);
return response.json();
}
// Example discovery document
// {
// "issuer": "https://auth.example.com",
// "authorization_endpoint": "https://auth.example.com/authorize",
// "token_endpoint": "https://auth.example.com/oauth/token",
// "userinfo_endpoint": "https://auth.example.com/userinfo",
// "jwks_uri": "https://auth.example.com/.well-known/jwks.json",
// "end_session_endpoint": "https://auth.example.com/logout",
// "scopes_supported": ["openid", "profile", "email", "offline_access"],
// "response_types_supported": ["code", "token", "id_token"],
// "grant_types_supported": ["authorization_code", "refresh_token", "client_credentials"],
// "subject_types_supported": ["public"],
// "id_token_signing_alg_values_supported": ["RS256"],
// "token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post", "private_key_jwt"]
// }
Client Credentials Flow (Server-to-Server)
// auth/clientCredentials.ts — Machine-to-machine authentication
export async function getClientCredentialsToken(
clientId: string,
clientSecret: string,
scope: string,
): Promise<TokenResponse> {
const response = await fetch('https://auth.example.com/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: clientId,
client_secret: clientSecret,
scope,
}),
});
return response.json();
}
// Usage: service-to-service calls
const token = await getClientCredentialsToken(
process.env.M2M_CLIENT_ID,
process.env.M2M_CLIENT_SECRET,
'read:orders write:orders',
);
const orders = await fetch('https://api.example.com/orders', {
headers: { Authorization: `Bearer ${token.access_token}` },
});
Best Practices
-
For a deeper guide, see Complete Guide to Authentication Patterns.
-
Always use PKCE for public clients (SPAs, mobile apps) — prevents code interception
-
Validate JWT signatures using JWKS — don’t hardcode keys, they rotate
-
Verify issuer, audience, and expiration — reject tokens from wrong sources
-
Use short-lived access tokens (15-60 minutes) — limit damage from token theft
-
Use refresh tokens with rotation — issue a new refresh token on each use, revoke the old one
-
Store tokens securely — HttpOnly cookies for web, secure storage for mobile
-
Use the
stateparameter — prevents CSRF attacks in the callback -
Use the
nonceparameter in OIDC — prevents replay attacks on ID tokens -
Validate scopes on every request — don’t trust the client to check
-
Use the discovery endpoint — don’t hardcode authorization/token URLs
-
Implement proper logout — call the end_session_endpoint to clear server-side session
-
Use
private_key_jwtfor confidential clients — more secure thanclient_secret_post
Common Mistakes
- Storing tokens in localStorage: JavaScript can read them, making XSS attacks catastrophic. Use HttpOnly cookies.
- Not using PKCE for SPAs: the authorization code can be intercepted. PKCE makes the code useless without the verifier.
- Not validating JWT signature: an attacker can forge tokens. Always verify with the JWKS public key.
- Long-lived access tokens: if stolen, they’re valid for a long time. Keep them short-lived with refresh rotation.
- Not checking token audience: a token issued for another API passes validation. Always check
aud. - Missing state validation: CSRF attacks can inject attacker’s authorization code. Always validate
state.
FAQ
What is the difference between OAuth2 and OIDC?
OAuth2 is an authorization framework — it handles what the client is allowed to do (scopes). OIDC is an authentication layer built on OAuth2 — it handles who the user is (ID token, userinfo). OIDC always includes OAuth2, but OAuth2 can be used without OIDC.
What is PKCE and why is it needed?
PKCE (Proof Key for Code Exchange) adds a cryptographic challenge to the authorization code flow. The client generates a verifier, sends a hash (challenge) to the authorization server, and proves it has the verifier when exchanging the code. This prevents an attacker from using an intercepted code.
What is the difference between access token and ID token?
Access tokens are for APIs — they authorize access to resources. They are opaque or JWT and are sent to the resource server. ID tokens are for the client — they contain user identity claims (email, name, sub). They are JWT and are consumed by the client, never sent to APIs.
When should I use client credentials flow?
For server-to-server communication where no user is involved. The client authenticates with its own credentials (ID + secret or private key) and gets a token with its own scopes. No user context, no refresh token.
How do I handle token revocation?
Call the revocation endpoint: POST /oauth/revoke with the token and token type. The authorization server invalidates the token. For refresh token rotation, the old refresh token is automatically revoked when a new one is issued.
Related Resources
CORS Security: Origins, Headers, Preflight, Credentials
Master CORS security: same-origin policy, CORS headers, preflight requests, credential handling, common misconfigurations, and production security patterns for web APIs.
GuideContent Security Policy: CSP Headers, Nonces, Hashes
Master Content Security Policy: CSP directives, nonces, hashes, reporting, strict-dynamic, nonce-based CSP, hash-based CSP, and production deployment patterns for web security.
RecipeImplement 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
GuideEncryption at Rest: AES-256, KMS, Envelope Encryption
Master encryption at rest: AES-256-GCM, key management services, envelope encryption, key rotation, database encryption, field-level encryption, and production security patterns.