OAuth 2.0 and OpenID Connect: A Practical Guide
Implement OAuth 2.0 and OpenID Connect correctly: authorization code flow with PKCE, token management, refresh rotation and the pitfalls behind takeovers.

OAuth 2.0 Is Authorization, Not Authentication
OAuth 2.0 answers "what can this application access?" not "who is this user?" OpenID Connect (OIDC) layers authentication on top of OAuth, providing identity tokens that tell you who the user is. Confusing the two leads to security vulnerabilities where an access token intended for one API is used as proof of identity at another.
Authorization Code Flow with PKCE
The Authorization Code flow with PKCE (Proof Key for Code Exchange) is the recommended flow for all clients—web apps, mobile apps, and SPAs. It prevents authorization code interception attacks.
import { randomBytes, createHash } from "node:crypto";
// Step 1: Generate PKCE challenge
function generatePKCE(): { verifier: string; challenge: string } {
const verifier = randomBytes(32)
.toString("base64url")
.slice(0, 128);
const challenge = createHash("sha256")
.update(verifier)
.digest("base64url");
return { verifier, challenge };
}
// Step 2: Build authorization URL
function buildAuthorizationURL(config: OAuthConfig): {
url: string;
state: string;
verifier: string;
} {
const { verifier, challenge } = generatePKCE();
const state = randomBytes(16).toString("hex");
const params = new URLSearchParams({
response_type: "code",
client_id: config.clientId,
redirect_uri: config.redirectUri,
scope: "openid profile email",
state,
code_challenge: challenge,
code_challenge_method: "S256",
});
return {
url: `${config.authorizationEndpoint}?${params}`,
state,
verifier,
};
}// ❌ Implicit flow — tokens exposed in URL fragment
// DEPRECATED: access_token appears in browser history and referrer headers
const badUrl = `${authEndpoint}?response_type=token&client_id=${clientId}`;
// ✅ Authorization Code + PKCE — tokens never in URLs
const { url, state, verifier } = buildAuthorizationURL(config);
// Store state and verifier in session, redirect user to urlToken Exchange
After the user authorizes, the authorization server redirects back with a code. Exchange it for tokens on the server side.
async function exchangeCodeForTokens(
code: string,
verifier: string,
config: OAuthConfig
): Promise<TokenResponse> {
const response = await fetch(config.tokenEndpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: config.redirectUri,
client_id: config.clientId,
client_secret: config.clientSecret,
code_verifier: verifier,
}),
});
if (!response.ok) {
const error = await response.json();
throw new AuthError(
`Token exchange failed: ${error.error_description ?? error.error}`
);
}
const tokens: TokenResponse = await response.json();
// Validate the ID token
if (tokens.id_token) {
await validateIdToken(tokens.id_token, config);
}
return tokens;
}
interface TokenResponse {
access_token: string;
token_type: "Bearer";
expires_in: number;
refresh_token?: string;
id_token?: string;
scope: string;
}ID Token Validation
The ID token is a JWT that contains identity claims. Validate it thoroughly—signature, issuer, audience, expiration, and nonce.
import { jwtVerify, createRemoteJWKSet } from "jose";
async function validateIdToken(
idToken: string,
config: OAuthConfig
): Promise<UserInfo> {
const jwks = createRemoteJWKSet(
new URL(config.jwksUri)
);
const { payload } = await jwtVerify(idToken, jwks, {
issuer: config.issuer,
audience: config.clientId,
maxTokenAge: "5m",
clockTolerance: "30s",
});
// Verify required claims exist
if (!payload.sub) {
throw new AuthError("ID token missing sub claim");
}
return {
id: payload.sub,
email: payload.email as string | undefined,
name: payload.name as string | undefined,
emailVerified: payload.email_verified as boolean | undefined,
};
}
interface UserInfo {
id: string;
email?: string;
name?: string;
emailVerified?: boolean;
}Refresh Token Rotation
Access tokens are short-lived (minutes). Refresh tokens get new access tokens without user interaction. Rotate refresh tokens on every use—each refresh token is single-use, and using an old one invalidates the entire session.
class TokenManager {
private refreshTimer: ReturnType<typeof setTimeout> | null = null;
constructor(
private readonly config: OAuthConfig,
private readonly storage: TokenStorage
) {}
async refreshAccessToken(): Promise<string> {
const refreshToken = await this.storage.getRefreshToken();
if (!refreshToken) {
throw new AuthError("No refresh token available — re-authentication required");
}
const response = await fetch(this.config.tokenEndpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: this.config.clientId,
client_secret: this.config.clientSecret,
}),
});
if (!response.ok) {
// Refresh token was revoked or expired
await this.storage.clearTokens();
throw new AuthError("Session expired — please log in again");
}
const tokens: TokenResponse = await response.json();
// Store the NEW refresh token (rotation)
await this.storage.setAccessToken(tokens.access_token, tokens.expires_in);
if (tokens.refresh_token) {
await this.storage.setRefreshToken(tokens.refresh_token);
}
this.scheduleRefresh(tokens.expires_in);
return tokens.access_token;
}
private scheduleRefresh(expiresInSeconds: number): void {
if (this.refreshTimer) clearTimeout(this.refreshTimer);
// Refresh 60 seconds before expiration
const refreshInMs = (expiresInSeconds - 60) * 1000;
this.refreshTimer = setTimeout(
() => this.refreshAccessToken(),
Math.max(refreshInMs, 0)
);
}
}Secure Token Storage
Where you store tokens matters. HttpOnly cookies for web apps. Secure storage APIs for mobile. Never localStorage for sensitive tokens.
// ❌ localStorage — accessible to any JavaScript on the page (XSS risk)
localStorage.setItem("access_token", tokens.access_token);
// ✅ HttpOnly, Secure, SameSite cookies — inaccessible to JavaScript
function setTokenCookie(
res: Response,
name: string,
value: string,
maxAge: number
): void {
res.setHeader("Set-Cookie", [
`${name}=${value}`,
"HttpOnly",
"Secure",
"SameSite=Lax",
`Max-Age=${maxAge}`,
"Path=/",
].join("; "));
}
// Set tokens as HttpOnly cookies after exchange
function handleCallback(req: Request, res: Response): void {
// ... exchange code for tokens ...
setTokenCookie(res, "access_token", tokens.access_token, tokens.expires_in);
if (tokens.refresh_token) {
setTokenCookie(res, "refresh_token", tokens.refresh_token, 30 * 24 * 3600);
}
res.redirect("/dashboard");
}Common Security Pitfalls
These mistakes appear in production OAuth implementations regularly and lead to account takeover.
interface SecurityChecklist {
check: string;
risk: string;
mitigation: string;
}
const oauthChecklist: SecurityChecklist[] = [
{
check: "State parameter validation",
risk: "CSRF attack — attacker forces victim to link their account",
mitigation: "Generate random state, store in session, validate on callback",
},
{
check: "PKCE on all flows",
risk: "Authorization code interception on public clients",
mitigation: "Always use S256 code challenge, even for confidential clients",
},
{
check: "Redirect URI exact match",
risk: "Open redirect allows token theft via crafted URLs",
mitigation: "Register exact redirect URIs, never wildcard or partial match",
},
{
check: "ID token audience validation",
risk: "Token confusion — token from another app used as identity proof",
mitigation: "Verify aud claim matches your client_id",
},
{
check: "Refresh token rotation",
risk: "Stolen refresh token grants indefinite access",
mitigation: "Rotate on use, detect reuse as compromise signal",
},
];Key Takeaways
Use Authorization Code flow with PKCE for all clients—web, mobile, and SPA. Never use the Implicit flow; it exposes tokens in URLs. Validate ID tokens thoroughly: signature, issuer, audience, expiration. Store tokens in HttpOnly cookies for web applications, never in localStorage.
Rotate refresh tokens on every use so stolen tokens are single-use. Validate the state parameter on every callback to prevent CSRF. Register exact redirect URIs—partial matching enables open redirect attacks. OAuth and OIDC are complex protocols with many sharp edges, but getting the implementation right is non-negotiable for any application that handles user identity.


