OAuth 2.0 and JWT: Authentication Patterns for Modern Apps
A comprehensive guide to OAuth 2.0 flows, JWT structure, token storage strategies, and security best practices for modern web and mobile applications.
Tags
OAuth 2.0 and JWT: Authentication Patterns for Modern Apps
OAuth 2.0 is an authorization framework that enables applications to obtain limited access to user accounts on third-party services, while JSON Web Tokens (JWTs) are a compact, self-contained format for securely transmitting identity claims between parties. Together, they form the backbone of modern authentication: OAuth 2.0 defines how authorization is delegated and tokens are obtained, while JWTs provide a stateless, verifiable token format that servers can validate without database lookups. In modern applications, the authorization code flow with PKCE is the recommended OAuth 2.0 grant type, access tokens should be short-lived JWTs stored in httpOnly cookies, and refresh tokens should be rotated on every use.
TL;DR
Use OAuth 2.0 authorization code flow with PKCE for all client types. Structure JWTs with minimal claims and short expiry. Store tokens in httpOnly secure cookies, never localStorage. Implement refresh token rotation with reuse detection. Use RBAC claims sparingly in tokens and validate permissions server-side.
Why This Matters
Authentication is the front door to your application. Get it wrong, and every other security measure is irrelevant. Yet authentication remains one of the most misunderstood areas in web development. Developers store tokens in localStorage and wonder why their users get hacked. Teams implement the implicit flow because a tutorial from 2018 told them to. Refresh tokens live forever without rotation, creating permanent backdoors when leaked.
The landscape has shifted significantly. The implicit flow is deprecated. PKCE is mandatory for public clients. Token storage best practices have changed. Understanding these patterns is not optional for any developer building applications that handle user identity.
How It Works
OAuth 2.0 Flows
OAuth 2.0 defines several grant types, but modern applications should focus on two: the authorization code flow and the client credentials flow.
Authorization Code Flow with PKCE is the recommended flow for all client types—server-side applications, single-page applications, and mobile apps. The flow works as follows:
- ›The client generates a random
code_verifierand computes its SHA-256 hash as thecode_challenge - ›The client redirects the user to the authorization server with the
code_challenge - ›The user authenticates and grants permission
- ›The authorization server redirects back with an authorization
code - ›The client exchanges the
codeplus the originalcode_verifierfor tokens - ›The authorization server verifies the
code_verifiermatches thecode_challengeand issues tokens
// Step 1: Generate PKCE values
async function generatePKCE() {
const codeVerifier = generateRandomString(64);
const encoder = new TextEncoder();
const data = encoder.encode(codeVerifier);
const digest = await crypto.subtle.digest("SHA-256", data);
const codeChallenge = base64UrlEncode(digest);
return { codeVerifier, codeChallenge };
}
// Step 2: Build authorization URL
function buildAuthUrl(codeChallenge: string): string {
const params = new URLSearchParams({
response_type: "code",
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
scope: "openid profile email",
code_challenge: codeChallenge,
code_challenge_method: "S256",
state: generateRandomString(32), // CSRF protection
});
return `${AUTH_SERVER}/authorize?${params}`;
}
// Step 5: Exchange code for tokens
async function exchangeCodeForTokens(
code: string,
codeVerifier: string
): Promise<TokenResponse> {
const response = await fetch(`${AUTH_SERVER}/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
client_id: CLIENT_ID,
code,
redirect_uri: REDIRECT_URI,
code_verifier: codeVerifier,
}),
});
return response.json();
}PKCE prevents authorization code interception. Without PKCE, an attacker who intercepts the authorization code (via a malicious browser extension, compromised redirect, or on mobile via a custom URI scheme conflict) can exchange it for tokens. With PKCE, the attacker also needs the code_verifier, which never leaves the client.
Client Credentials Flow is for machine-to-machine communication where no user is involved. A service authenticates with its own credentials to access another service's API.
async function getServiceToken(): Promise<string> {
const response = await fetch(`${AUTH_SERVER}/token`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Basic ${btoa(`${CLIENT_ID}:${CLIENT_SECRET}`)}`,
},
body: new URLSearchParams({
grant_type: "client_credentials",
scope: "api:read api:write",
}),
});
const data = await response.json();
return data.access_token;
}JWT Structure
A JWT consists of three Base64URL-encoded parts separated by dots: header, payload, and signature.
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiJ1c2VyLTEyMyIsImVtYWlsIjoiam9obkBleGFtcGxlLmNvbSIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTcwNDQ2NzIwMCwiZXhwIjoxNzA0NDY4MTAwfQ.
signature_bytes_here
Header specifies the algorithm and token type:
{
"alg": "RS256",
"typ": "JWT",
"kid": "key-id-2024-01"
}Payload contains claims about the subject:
{
"sub": "user-123",
"email": "john@example.com",
"role": "admin",
"iat": 1704467200,
"exp": 1704468100,
"iss": "https://auth.example.com",
"aud": "https://api.example.com"
}Signature ensures the token has not been tampered with. Use RS256 (RSA with SHA-256) for production applications. The authorization server signs with a private key, and resource servers verify with the corresponding public key. This means resource servers never need the signing secret.
Access Tokens vs Refresh Tokens
Access tokens and refresh tokens serve fundamentally different purposes and have different security properties.
Access tokens are short-lived (5-15 minutes), sent with every API request, and contain the claims needed for authorization. Because they are short-lived, a compromised access token has a limited damage window.
Refresh tokens are long-lived (hours to days), stored securely, and used only to obtain new access tokens. They are never sent to resource servers. They are sent only to the authorization server's token endpoint.
// Token refresh middleware
async function refreshAccessToken(
refreshToken: string
): Promise<TokenResponse> {
const response = await fetch(`${AUTH_SERVER}/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
client_id: CLIENT_ID,
refresh_token: refreshToken,
}),
});
if (!response.ok) {
// Refresh token expired or revoked - redirect to login
throw new AuthenticationError("Session expired");
}
return response.json();
}
// Axios interceptor for automatic token refresh
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
const tokens = await refreshAccessToken(getStoredRefreshToken());
storeTokens(tokens);
originalRequest.headers.Authorization = `Bearer ${tokens.access_token}`;
return api(originalRequest);
} catch {
logout();
return Promise.reject(error);
}
}
return Promise.reject(error);
}
);Token Storage: Cookies vs localStorage
This is one of the most consequential security decisions in frontend authentication.
localStorage is accessible to any JavaScript running on the page. A single XSS vulnerability—from a compromised npm package, an injected script, or a reflected XSS attack—gives the attacker full access to stored tokens. There is no mitigation for this within localStorage itself.
httpOnly cookies cannot be read by JavaScript. They are automatically included in requests to the same origin. Combined with the Secure flag (HTTPS only) and SameSite=Strict (or Lax), cookies provide defense-in-depth that localStorage cannot match.
// Server-side: Set tokens as httpOnly cookies
import { Response } from "express";
function setAuthCookies(res: Response, tokens: TokenResponse): void {
// Access token - short-lived, httpOnly
res.cookie("access_token", tokens.access_token, {
httpOnly: true,
secure: true,
sameSite: "strict",
maxAge: 15 * 60 * 1000, // 15 minutes
path: "/",
});
// Refresh token - longer-lived, restricted path
res.cookie("refresh_token", tokens.refresh_token, {
httpOnly: true,
secure: true,
sameSite: "strict",
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
path: "/api/auth/refresh", // Only sent to refresh endpoint
});
}The refresh token's path restriction is important. By limiting it to the refresh endpoint, the refresh token is never sent with regular API requests, reducing its exposure surface.
For SPAs that need to know if the user is authenticated (to show/hide UI elements), set a non-httpOnly cookie or use a dedicated /me endpoint rather than exposing the JWT to JavaScript.
Session Management
Stateless JWTs have a fundamental limitation: they cannot be revoked before expiry. If a user logs out or an admin deactivates an account, the access token remains valid until it expires.
Solutions to this problem:
Short access token lifetimes (5-15 minutes) limit the revocation gap. Combined with refresh token rotation, this provides practical revocation within minutes.
Token blocklist maintains a set of revoked token IDs (the jti claim) in a fast store like Redis. Check every request against the blocklist. This adds a database lookup but provides immediate revocation.
// Middleware: Check token blocklist
async function checkTokenRevocation(
req: Request,
res: Response,
next: NextFunction
) {
const token = extractToken(req);
const decoded = verifyToken(token);
const isRevoked = await redis.get(`revoked:${decoded.jti}`);
if (isRevoked) {
return res.status(401).json({ error: "Token has been revoked" });
}
req.user = decoded;
next();
}
// Logout: Revoke current tokens
async function logout(req: Request, res: Response) {
const decoded = req.user;
const ttl = decoded.exp - Math.floor(Date.now() / 1000);
// Add to blocklist with TTL matching token expiry
await redis.setex(`revoked:${decoded.jti}`, ttl, "1");
// Revoke refresh token family
await redis.del(`refresh_family:${decoded.sub}`);
res.clearCookie("access_token");
res.clearCookie("refresh_token", { path: "/api/auth/refresh" });
res.json({ message: "Logged out" });
}RBAC Integration
Role-Based Access Control claims can be included in JWTs, but keep them minimal. Embedding detailed permissions in tokens makes them large and forces re-issuance when permissions change.
// JWT payload with RBAC claims
interface TokenPayload {
sub: string;
role: "admin" | "editor" | "viewer";
permissions?: string[]; // Only for fine-grained needs
org: string;
}
// Authorization middleware
function requireRole(...roles: string[]) {
return (req: Request, res: Response, next: NextFunction) => {
const user = req.user as TokenPayload;
if (!roles.includes(user.role)) {
return res.status(403).json({ error: "Insufficient permissions" });
}
next();
};
}
// Usage
router.delete("/users/:id", requireRole("admin"), deleteUserHandler);
router.put("/posts/:id", requireRole("admin", "editor"), updatePostHandler);
router.get("/posts", requireRole("admin", "editor", "viewer"), listPostsHandler);For complex permission models, include only the role in the JWT and look up granular permissions server-side. This avoids bloated tokens and allows permission changes to take effect without waiting for token refresh.
Practical Implementation
Complete Authentication Flow
Here is a complete server-side implementation combining all the patterns:
// auth.controller.ts
import jwt from "jsonwebtoken";
import { v4 as uuid } from "uuid";
const ACCESS_TOKEN_TTL = "15m";
const REFRESH_TOKEN_TTL = "7d";
function generateTokenPair(user: User): TokenPair {
const accessToken = jwt.sign(
{
sub: user.id,
email: user.email,
role: user.role,
jti: uuid(),
},
PRIVATE_KEY,
{
algorithm: "RS256",
expiresIn: ACCESS_TOKEN_TTL,
issuer: "https://auth.example.com",
audience: "https://api.example.com",
}
);
const refreshToken = jwt.sign(
{
sub: user.id,
jti: uuid(),
family: uuid(), // Token family for rotation detection
},
REFRESH_SECRET,
{ expiresIn: REFRESH_TOKEN_TTL }
);
return { accessToken, refreshToken };
}
async function handleRefresh(req: Request, res: Response) {
const { refresh_token } = req.cookies;
try {
const decoded = jwt.verify(refresh_token, REFRESH_SECRET) as RefreshPayload;
// Check if this refresh token has already been used
const isUsed = await redis.get(`used_refresh:${decoded.jti}`);
if (isUsed) {
// Possible token theft - invalidate entire family
await redis.del(`refresh_family:${decoded.family}`);
return res.status(401).json({ error: "Token reuse detected" });
}
// Mark current refresh token as used
await redis.setex(`used_refresh:${decoded.jti}`, 7 * 24 * 3600, "1");
const user = await findUserById(decoded.sub);
const tokens = generateTokenPair(user);
setAuthCookies(res, tokens);
res.json({ message: "Tokens refreshed" });
} catch {
res.status(401).json({ error: "Invalid refresh token" });
}
}Token family tracking is the key to refresh token rotation security. Each initial login creates a new family. When a refresh token is used, the server issues a new one in the same family and marks the old one as used. If a used token is presented again, it means either the legitimate user or an attacker has a stale token, so the entire family is invalidated, forcing a new login.
Common Pitfalls
Using the implicit flow. The implicit flow returns tokens directly in the URL fragment, exposing them in browser history, referrer headers, and server logs. It is deprecated by the OAuth 2.0 Security Best Current Practice. Use authorization code with PKCE instead.
Storing sensitive data in JWT payloads. JWTs are encoded, not encrypted. Anyone with the token can decode the payload. Never include passwords, social security numbers, or any sensitive data in JWT claims.
Not validating all JWT claims. Always verify iss (issuer), aud (audience), and exp (expiration) in addition to the signature. A valid signature from the wrong issuer or for the wrong audience is still a security violation.
Infinite refresh token lifetime. Refresh tokens without expiration create permanent session backdoors. Always set expiration and implement rotation with reuse detection.
Symmetric signing in distributed systems. Using HS256 (HMAC) means every service that validates tokens needs the secret, and any of those services could create forged tokens. Use RS256 (RSA) so only the auth server has the private signing key while any service can verify with the public key.
When to Use (and When Not To)
Use OAuth 2.0 with JWT when:
- ›You need to integrate with third-party identity providers (Google, GitHub, Azure AD)
- ›Your architecture involves multiple services that need to verify identity independently
- ›You want stateless authentication that scales horizontally
- ›Your application serves both web and mobile clients
Consider simpler alternatives when:
- ›Your application is a monolith with server-rendered pages—session cookies may be simpler
- ›You have no third-party authentication requirements
- ›Your team does not have the security expertise to implement token handling correctly
- ›The application has a single backend and no API consumers
FAQ
What is the difference between OAuth 2.0 and JWT? OAuth 2.0 is an authorization framework that defines how applications obtain limited access to user accounts. JWT is a token format for securely transmitting claims between parties. OAuth 2.0 can use JWTs as its token format, but they solve different problems. OAuth defines the flow for obtaining and refreshing tokens, while JWT defines how those tokens are structured and verified.
Should I store JWTs in localStorage or cookies? Store JWTs in httpOnly, secure, SameSite cookies for web applications. localStorage is accessible to any JavaScript running on the page, making it vulnerable to XSS attacks. HttpOnly cookies cannot be accessed by JavaScript, are automatically sent with same-origin requests, and provide defense-in-depth when combined with Secure and SameSite attributes.
What is PKCE and why do I need it? PKCE (Proof Key for Code Exchange) prevents authorization code interception attacks by requiring the client to prove it initiated the authorization request. The client generates a random code_verifier, sends its hash as code_challenge with the authorization request, and later presents the original code_verifier when exchanging the code for tokens. It is mandatory for public clients like SPAs and mobile apps that cannot securely store a client secret.
How long should access tokens live? Access tokens should have short lifetimes, typically 5 to 15 minutes. Short-lived access tokens limit the damage window if a token is compromised. Pair them with longer-lived refresh tokens (hours to days) that can be revoked and rotated. The exact duration depends on your security requirements—financial applications may use 5-minute tokens, while internal tools might tolerate 15-minute tokens.
What is token rotation and why does it matter? Token rotation means issuing a new refresh token every time one is used, and invalidating the old one. If an attacker steals a refresh token and both the attacker and legitimate user attempt to use it, the rotation detects the reuse and invalidates the entire token family, forcing both parties to re-authenticate. This limits the blast radius of refresh token theft to a single use.
Collaboration
Need help with a project?
Let's Build It
I help startups and established companies design, build, and scale world-class digital products. From deep technical architecture to pixel-perfect UI — let's bring your vision to life.
Related Articles
How to Design API Contracts Between Micro-Frontends and BFFs
Learn how to design stable API contracts between Micro-Frontends and Backend-for-Frontend layers with versioning, ownership boundaries, error handling, and schema governance.
Next.js BFF Architecture
An architectural deep dive into using Next.js as a Backend-for-Frontend, including route handlers, server components, auth boundaries, caching, and service orchestration.
Next.js Cache Components and PPR in Real Apps
A practical guide to using Next.js Cache Components and Partial Prerendering in real applications, with tradeoffs, cache strategy, and freshness considerations.