Blog/Deep Dives/OAuth 2.0 and JWT: Authentication Patterns for Modern Apps
POST
January 05, 2026
LAST UPDATEDJanuary 05, 2026

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

OAuthJWTAuthenticationSecurity
OAuth 2.0 and JWT: Authentication Patterns for Modern Apps
9 min read

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:

  1. The client generates a random code_verifier and computes its SHA-256 hash as the code_challenge
  2. The client redirects the user to the authorization server with the code_challenge
  3. The user authenticates and grants permission
  4. The authorization server redirects back with an authorization code
  5. The client exchanges the code plus the original code_verifier for tokens
  6. The authorization server verifies the code_verifier matches the code_challenge and issues tokens
typescript
// 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.

typescript
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:

json
{
  "alg": "RS256",
  "typ": "JWT",
  "kid": "key-id-2024-01"
}

Payload contains claims about the subject:

json
{
  "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.

typescript
// 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.

typescript
// 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.

typescript
// 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.

typescript
// 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:

typescript
// 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.

SH

Article Author

Sadam Hussain

Senior Full Stack Developer

Senior Full Stack Developer with over 7 years of experience building React, Next.js, Node.js, TypeScript, and AI-powered web platforms.

Related Articles

How to Design API Contracts Between Micro-Frontends and BFFs
Mar 21, 20266 min read
Micro-Frontends
BFF
API Design

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
Mar 21, 20261 min read
Next.js
BFF
Architecture

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
Mar 21, 20266 min read
Next.js
Performance
Caching

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.