Showing posts with label jwt. Show all posts
Showing posts with label jwt. Show all posts

Friday, April 17, 2026

Authentication in 2026: JWT Security, OAuth 2.0 + PKCE, Token Rotation, and Session Management

Hero image

Introduction

Authentication is the most-exploited surface in web applications. It sits at the intersection of cryptography, protocol design, and application logic — and misconfiguration at any layer can be catastrophic. JWT algorithm confusion, broken OAuth flows, and session fixation attacks collectively account for a disproportionate share of real-world breaches. The 2021 Coinbase breach, the 2022 Okta hack, and countless smaller incidents all trace back to authentication logic that was almost right.

In 2026, the attack surface has expanded. Applications run in edge environments where stateless tokens are preferred. Single-page applications consume tokens directly in the browser. Mobile apps use native OAuth flows. Microservices validate tokens at every service boundary. Each of these scenarios introduces new ways to get authentication wrong.

At the same time, the defensive toolkit has matured. PKCE is now a non-negotiable standard for all OAuth clients, not just public ones. Passkeys and WebAuthn have crossed the adoption threshold from "experimental" to "production-ready for consumer apps." Token binding proposals are gaining traction. Short-lived access tokens with refresh token rotation are understood as the correct baseline. And Redis-backed server-side sessions remain the gold standard when stateful control is required.

This post covers the full 2026 authentication stack for production applications. We will go deep on each layer: the JWT vulnerabilities that still catch teams off guard, the only correct OAuth flow for browser clients, refresh token rotation with theft detection, server-side session management with Redis, and WebAuthn/passkey integration with the SimpleWebAuthn library. Every code example is complete and production-oriented, with comments explaining the security rationale behind each decision.

The goal is not a survey — it is an opinionated implementation guide. By the end, you will have the patterns for a hardened authentication system you can deploy today.


1. JWT Security: The Vulnerabilities Teams Miss

JSON Web Tokens are everywhere. They are also misimplemented everywhere. The format is simple — a base64-encoded header, payload, and signature — but the attack surface is larger than it looks. Let us walk through the vulnerabilities that appear repeatedly in security audits, with code showing how to close each one.

The "none" Algorithm Attack

The JWT specification includes an algorithm value of none, which means the token carries no signature. A server that accepts this value trusts whatever is in the payload without cryptographic verification. This sounds too obvious to be a real vulnerability, but the CVE list includes multiple JWT libraries that accepted none by default: node-jsonwebtoken before 4.2.2 (CVE-2015-9235), python-jwt (CVE-2022-39227), and others.

The attack is straightforward: take a valid token, change the algorithm to none, strip the signature, modify the payload to elevate privileges, and send it. If the server does not explicitly reject none, it trusts the forged token.

Algorithm Confusion: RS256 Public Key as HS256 Secret

This is a subtler and more dangerous vulnerability. RS256 uses an asymmetric key pair: the server signs with a private key and verifies with a public key. HS256 uses a single symmetric secret for both signing and verification. The attack: an attacker obtains the RS256 public key (often exposed at a JWKS endpoint), then crafts a token signed with HS256 using the public key as the secret. If the verification code blindly uses the algorithm from the token header rather than asserting the expected algorithm, it will call the HS256 verifier with the public key as the secret — and the verification succeeds.

Fix: always specify the expected algorithm explicitly. Never trust the algorithm from the token header.

Weak HS256 Secrets

HS256 HMAC-SHA256 can be brute-forced if the secret is short or guessable. jwt_tool and hashcat can crack common secrets offline against a captured token in seconds. The fix is straightforward: use a cryptographically random secret of at least 256 bits (32 bytes). In practice, crypto.randomBytes(32).toString('hex') gives you a 64-character hex string that is unguessable.

Missing exp, aud, and iss Validation

A token without an expiry (exp claim) is valid forever. A token without audience validation (aud claim) can be used against any service that shares the same signing key. A token without issuer validation (iss claim) can be replayed from a different identity provider. These are all required claims that many implementations simply do not validate.

The practical consequence: a token stolen from a low-value service (maybe a dev environment) can be replayed against a production service if audience validation is absent. This exact pattern was part of the OAuth token confusion attacks documented in 2023 OAuth security workshop findings.

localStorage vs httpOnly Cookies

Storing JWTs in localStorage is wrong. Full stop. localStorage is accessible to any JavaScript running on the page, which means a single XSS vulnerability anywhere on the domain gives an attacker full token theft. The token exfiltrates silently, the session is hijacked, and the user has no idea.

The correct storage is an httpOnly; Secure; SameSite=Strict cookie. httpOnly means JavaScript cannot read it. Secure means it only transmits over HTTPS. SameSite=Strict prevents cross-site request forgery. The cookie is invisible to JavaScript, so XSS cannot steal it (though CSRF via cookie still requires the SameSite attribute, which you are setting).

The objection to cookies is usually "but I'm building a mobile app or SPA." For mobile: use the platform secure credential store, not localStorage. For SPAs served from the same domain as your API: httpOnly cookies work correctly. For cross-origin SPAs: set SameSite=None; Secure and handle the CORS preflight correctly, or use a backend-for-frontend (BFF) pattern.

JWT Revocation: The Stateless Trade-off

JWTs are stateless — you cannot revoke them without a lookup. The common solution of "just set a short expiry" is correct, but incomplete without refresh token rotation. The full pattern is:

  • Access tokens: 15-minute expiry, no revocation needed
  • Refresh tokens: 7-day expiry, stored server-side, rotated on every use
  • On logout: delete the refresh token from the server

This gives you revocation control at the refresh token level. An attacker who steals an access token has 15 minutes. An attacker who steals a refresh token will be detected on next use if rotation is correctly implemented (see Section 3).

JWT Validation Middleware: Complete Implementation

import { Request, Response, NextFunction } from 'express';
import jwt, { JwtPayload } from 'jsonwebtoken';

// All expected values must be asserted explicitly —
// never derive them from the token itself.
interface TokenConfig {
  secret: string;           // HS256 secret (min 32 bytes random)
  issuer: string;           // e.g., "https://auth.example.com"
  audience: string;         // e.g., "https://api.example.com"
  algorithms: jwt.Algorithm[]; // Explicitly allowlist — never trust the header
}

interface AuthenticatedRequest extends Request {
  user?: JwtPayload;
}

export function createJwtMiddleware(config: TokenConfig) {
  return function jwtMiddleware(
    req: AuthenticatedRequest,
    res: Response,
    next: NextFunction
  ): void {
    // Extract from httpOnly cookie — NOT Authorization header for browser clients.
    // Authorization header is fine for server-to-server calls where cookies don't apply.
    const token = req.cookies?.access_token;

    if (!token) {
      res.status(401).json({ error: 'No token provided' });
      return;
    }

    try {
      const payload = jwt.verify(token, config.secret, {
        // Explicitly specify allowed algorithms.
        // This prevents the "none" algorithm attack and RS256/HS256 confusion.
        algorithms: config.algorithms,

        // Validate issuer — prevents tokens from a different IdP being accepted.
        issuer: config.issuer,

        // Validate audience — prevents token replay across services.
        audience: config.audience,

        // exp is validated automatically by jsonwebtoken when this is true (default).
        // Setting it explicitly as documentation of intent.
        ignoreExpiration: false,
      }) as JwtPayload;

      // Additional claim validation beyond what jsonwebtoken handles.
      if (!payload.sub) {
        // sub (subject) must be present — this is the user identifier.
        res.status(401).json({ error: 'Invalid token: missing subject' });
        return;
      }

      if (!payload.iat) {
        // Issued-at must be present for token age reasoning.
        res.status(401).json({ error: 'Invalid token: missing iat' });
        return;
      }

      // Attach validated payload to request for downstream handlers.
      req.user = payload;
      next();
    } catch (error) {
      if (error instanceof jwt.TokenExpiredError) {
        // Return a specific error code so the client knows to refresh.
        res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
        return;
      }
      if (error instanceof jwt.JsonWebTokenError) {
        // Covers: invalid signature, malformed token, algorithm mismatch.
        res.status(401).json({ error: 'Invalid token' });
        return;
      }
      // Unexpected error — do not leak details.
      res.status(500).json({ error: 'Internal server error' });
    }
  };
}

// Usage:
// app.use('/api', createJwtMiddleware({
//   secret: process.env.JWT_SECRET!, // 64-char hex from crypto.randomBytes(32)
//   issuer: 'https://auth.example.com',
//   audience: 'https://api.example.com',
//   algorithms: ['HS256'], // Only HS256 — never include 'none'
// }));
Architecture diagram
sequenceDiagram participant C as Client participant A as Auth Server participant R as Resource API C->>A: POST /token (credentials) A-->>C: access_token (15m) + refresh_token (7d) Note over C: Store in httpOnly cookie C->>R: GET /api/resource (access_token cookie) R->>R: Validate exp, aud, iss, sig R-->>C: 200 OK Note over C,R: 15 minutes later — token expires C->>R: GET /api/resource (expired access_token) R-->>C: 401 TOKEN_EXPIRED C->>A: POST /token/refresh (refresh_token cookie) A->>A: Validate refresh_token in DB A->>A: Issue new access_token + rotate refresh_token A-->>C: new access_token + new refresh_token Note over A: Old refresh_token marked invalid C->>R: GET /api/resource (new access_token) R-->>C: 200 OK

2. OAuth 2.0 + PKCE: The Correct Flow in 2026

Why the Implicit Flow Is Dead

The OAuth 2.0 implicit flow was designed for single-page applications in an era before CORS was well-supported. It delivered access tokens directly in the URL fragment (e.g., https://app.example.com/callback#access_token=eyJ...). This created two critical problems:

  1. Tokens in URLs end up in browser history, server logs, and referrer headers. Any server receiving a request from the app (analytics, CDN logs, third-party scripts) sees the access token in the referer.
  2. No refresh tokens. The implicit flow cannot issue refresh tokens because there is no back-channel. Users get logged out when the short-lived token expires.

RFC 9700 (OAuth 2.0 Security Best Current Practice) formally deprecated the implicit flow in 2025. It is gone. Do not use it.

Authorization Code + PKCE: The Only Correct Browser Flow

Proof Key for Code Exchange (PKCE, RFC 7636) was originally designed for mobile clients that cannot keep secrets. The insight: if you cannot have a static client secret, generate a per-request secret instead.

PKCE works as follows:

  1. The client generates a cryptographically random code_verifier (43-128 characters, URL-safe).
  2. The client computes code_challenge = BASE64URL(SHA256(code_verifier)).
  3. The authorization request includes code_challenge and code_challenge_method=S256.
  4. The authorization server stores the challenge.
  5. The client receives an authorization code.
  6. The token exchange request includes the original code_verifier.
  7. The authorization server verifies SHA256(code_verifier) == stored_challenge before issuing tokens.

An attacker who intercepts the authorization code cannot exchange it for tokens — they do not have the code_verifier that was never transmitted. This closes the authorization code interception attack that motivated the original PKCE RFC.

In 2026, PKCE is required for all public clients and strongly recommended for confidential clients as defense-in-depth.

State and Nonce: CSRF and Replay Protection

The state parameter is how you prevent CSRF in OAuth flows. Generate a random value before redirecting to the authorization endpoint, store it in the session, and verify it on callback. If the state in the callback does not match what you stored, the request was forged.

The nonce is the OIDC equivalent for ID tokens — it prevents replay attacks. Include a random nonce in the authorization request; the authorization server embeds it in the ID token; you verify it on receipt.

Complete PKCE Implementation: TypeScript Client + Server

// === CLIENT SIDE (browser) ===
// crypto.subtle is available in all modern browsers and Node.js 18+

async function generatePKCE(): Promise<{ verifier: string; challenge: string }> {
  // Generate a cryptographically random code_verifier.
  // 32 bytes = 43 base64url characters (within the 43-128 required range).
  const randomBytes = crypto.getRandomValues(new Uint8Array(32));
  const verifier = base64urlEncode(randomBytes);

  // Compute SHA-256 of the verifier.
  const encoder = new TextEncoder();
  const data = encoder.encode(verifier);
  const digest = await crypto.subtle.digest('SHA-256', data);

  const challenge = base64urlEncode(new Uint8Array(digest));
  return { verifier, challenge };
}

function base64urlEncode(buffer: Uint8Array): string {
  // Standard base64, then convert to URL-safe variant.
  return btoa(String.fromCharCode(...buffer))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=/g, '');
}

async function startOAuthFlow(config: {
  authEndpoint: string;
  clientId: string;
  redirectUri: string;
  scope: string;
}) {
  const { verifier, challenge } = await generatePKCE();

  // Generate state for CSRF protection.
  const stateBytes = crypto.getRandomValues(new Uint8Array(16));
  const state = base64urlEncode(stateBytes);

  // Generate nonce for OIDC replay protection.
  const nonceBytes = crypto.getRandomValues(new Uint8Array(16));
  const nonce = base64urlEncode(nonceBytes);

  // Store verifier, state, and nonce in sessionStorage.
  // sessionStorage is cleared on tab close — not persistent like localStorage.
  // These values are never sent to the server except via back-channel exchange.
  sessionStorage.setItem('pkce_verifier', verifier);
  sessionStorage.setItem('oauth_state', state);
  sessionStorage.setItem('oidc_nonce', nonce);

  const params = new URLSearchParams({
    response_type: 'code',
    client_id: config.clientId,
    redirect_uri: config.redirectUri,
    scope: config.scope,
    state,
    nonce,
    code_challenge: challenge,
    code_challenge_method: 'S256',
  });

  // Redirect to authorization server.
  window.location.href = `${config.authEndpoint}?${params}`;
}

async function handleOAuthCallback(): Promise<void> {
  const params = new URLSearchParams(window.location.search);
  const code = params.get('code');
  const returnedState = params.get('state');
  const error = params.get('error');

  if (error) {
    throw new Error(`OAuth error: ${error} — ${params.get('error_description')}`);
  }

  // Verify state to prevent CSRF.
  const storedState = sessionStorage.getItem('oauth_state');
  if (!returnedState || returnedState !== storedState) {
    throw new Error('State mismatch — possible CSRF attack');
  }

  const verifier = sessionStorage.getItem('pkce_verifier');
  if (!verifier || !code) {
    throw new Error('Missing PKCE verifier or authorization code');
  }

  // Exchange code for tokens via your backend (never from the browser directly —
  // the token endpoint exchange should happen server-side to avoid exposing
  // client credentials in browser requests if using a confidential client).
  const response = await fetch('/api/auth/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ code, verifier }),
    credentials: 'include', // Include cookies so server can set httpOnly tokens
  });

  if (!response.ok) {
    throw new Error('Token exchange failed');
  }

  // Tokens are set as httpOnly cookies by the server — no JS access.
  // Clean up sessionStorage.
  sessionStorage.removeItem('pkce_verifier');
  sessionStorage.removeItem('oauth_state');
  sessionStorage.removeItem('oidc_nonce');
}


// === SERVER SIDE (Node.js/Express) ===
import axios from 'axios';

interface TokenExchangeRequest {
  code: string;
  verifier: string;
}

async function exchangeCodeForTokens(
  req: Request & { body: TokenExchangeRequest },
  res: Response
): Promise<void> {
  const { code, verifier } = req.body;

  if (!code || !verifier) {
    res.status(400).json({ error: 'Missing code or verifier' });
    return;
  }

  try {
    // Exchange code + verifier at the authorization server token endpoint.
    // This is a back-channel request — the client secret never leaves the server.
    const tokenResponse = await axios.post(
      process.env.TOKEN_ENDPOINT!,
      new URLSearchParams({
        grant_type: 'authorization_code',
        client_id: process.env.OAUTH_CLIENT_ID!,
        client_secret: process.env.OAUTH_CLIENT_SECRET!, // Only for confidential clients
        redirect_uri: process.env.OAUTH_REDIRECT_URI!,
        code,
        code_verifier: verifier, // The authorization server verifies SHA256(verifier) == stored challenge
      }),
      { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
    );

    const { access_token, refresh_token, expires_in } = tokenResponse.data;

    // Set tokens as httpOnly cookies — never return them in the response body.
    res.cookie('access_token', access_token, {
      httpOnly: true,   // Not accessible to JavaScript
      secure: true,     // HTTPS only
      sameSite: 'strict', // CSRF protection
      maxAge: expires_in * 1000,
    });

    res.cookie('refresh_token', refresh_token, {
      httpOnly: true,
      secure: true,
      sameSite: 'strict',
      maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
      path: '/api/auth/refresh', // Only sent to the refresh endpoint
    });

    res.json({ success: true });
  } catch (error) {
    res.status(401).json({ error: 'Token exchange failed' });
  }
}
sequenceDiagram participant U as User Browser participant C as Client App participant AS as Auth Server participant TS as Token Store (Server) C->>C: Generate code_verifier (random 32 bytes) C->>C: code_challenge = BASE64URL(SHA256(verifier)) C->>C: Store verifier in sessionStorage C->>C: Generate state (CSRF) + nonce (replay) U->>AS: Redirect: /authorize?code_challenge=X&state=Y&nonce=Z AS->>TS: Store code_challenge for this session U->>U: Login / consent AS-->>U: Redirect to callback?code=AUTH_CODE&state=Y C->>C: Verify returned state == stored state (CSRF check) C->>TS: POST /api/auth/token {code, verifier} TS->>AS: POST /token {code, code_verifier, client_secret} AS->>AS: Verify SHA256(verifier) == stored challenge AS-->>TS: access_token + refresh_token TS-->>C: Set httpOnly cookies (tokens never in JS) C->>C: Clear sessionStorage

3. Token Rotation and Refresh Strategy

The Baseline: Short Access Tokens + Long Refresh Tokens

A 15-minute access token expiry is the right balance for most applications. It limits the window of exposure if a token is stolen while keeping the user experience smooth (clients transparently refresh in the background). Refresh tokens live longer — 7 days is common — but they are stored server-side and rotated on every use.

The key insight is that refresh token rotation converts a stateless mechanism into a stateful one at the refresh layer. You get the scalability of JWT access tokens while retaining revocation control at the refresh layer.

Refresh Token Family Detection

Refresh token family detection is the theft-detection mechanism. Here is the logic:

  • Every refresh token belongs to a "family" (a chain originating from the initial login).
  • When a refresh token is used, it is invalidated and a new one is issued in the same family.
  • If an already-invalidated refresh token is presented, it means either the client has a bug or the token was stolen and used by an attacker before the legitimate client could use it.
  • On detecting a used-and-rotated token, invalidate the entire family — forcing re-authentication.

This is the pattern described in the Auth0 security whitepaper and implemented in most production identity platforms. It was formalized as a best practice in RFC 9700.

Sliding vs Absolute Expiry

Sliding expiry extends the refresh token lifetime on each use. Absolute expiry sets a hard deadline from initial issue. Sliding expiry improves UX for active users (they never get logged out while using the app) but can theoretically keep a token alive indefinitely if used consistently. Use absolute expiry for high-security applications (banking, healthcare) and sliding expiry for consumer apps where session continuity is more important than hard session limits.

Complete Refresh Rotation Implementation

import { createClient } from 'redis';
import crypto from 'crypto';
import jwt from 'jsonwebtoken';

interface RefreshToken {
  token: string;
  userId: string;
  familyId: string;    // All tokens in a rotation chain share a familyId
  parentToken: string | null; // The token this was rotated from (null for initial token)
  isValid: boolean;
  createdAt: number;
  expiresAt: number;
}

const redis = createClient({ url: process.env.REDIS_URL });

async function issueTokenPair(userId: string, existingFamilyId?: string): Promise<{
  accessToken: string;
  refreshToken: string;
}> {
  // Access token: short-lived JWT, no server-side storage needed.
  const accessToken = jwt.sign(
    {
      sub: userId,
      iss: process.env.JWT_ISSUER,
      aud: process.env.JWT_AUDIENCE,
      iat: Math.floor(Date.now() / 1000),
    },
    process.env.JWT_SECRET!,
    { expiresIn: '15m', algorithm: 'HS256' }
  );

  // Refresh token: opaque random value, stored in Redis.
  const refreshToken = crypto.randomBytes(40).toString('hex');
  const familyId = existingFamilyId ?? crypto.randomUUID();
  const expiresAt = Date.now() + 7 * 24 * 60 * 60 * 1000; // 7 days

  const tokenData: RefreshToken = {
    token: refreshToken,
    userId,
    familyId,
    parentToken: null,
    isValid: true,
    createdAt: Date.now(),
    expiresAt,
  };

  // Store with TTL so Redis auto-expires stale tokens.
  await redis.setEx(
    `refresh:${refreshToken}`,
    7 * 24 * 60 * 60, // 7 days in seconds
    JSON.stringify(tokenData)
  );

  return { accessToken, refreshToken };
}

async function rotateRefreshToken(incomingToken: string): Promise<{
  accessToken: string;
  refreshToken: string;
} | null> {
  const raw = await redis.get(`refresh:${incomingToken}`);

  if (!raw) {
    // Token not found — could be expired, already rotated, or never existed.
    // Do not leak which case this is.
    return null;
  }

  const tokenData: RefreshToken = JSON.parse(raw);

  if (!tokenData.isValid) {
    // CRITICAL: This token was already rotated. This is a theft signal.
    // Invalidate the entire family to force re-authentication.
    // The legitimate user will be logged out, but so will the attacker.
    await invalidateFamily(tokenData.familyId);
    console.warn(`Refresh token reuse detected — family ${tokenData.familyId} invalidated`, {
      userId: tokenData.userId,
      token: incomingToken.slice(0, 8) + '...',
    });
    return null;
  }

  if (Date.now() > tokenData.expiresAt) {
    // Token has expired — legitimate expiry, not an attack.
    return null;
  }

  // Mark the incoming token as used (invalid for future use).
  tokenData.isValid = false;
  await redis.setEx(`refresh:${incomingToken}`, 7 * 24 * 60 * 60, JSON.stringify(tokenData));

  // Issue a new token pair in the same family.
  return issueTokenPair(tokenData.userId, tokenData.familyId);
}

async function invalidateFamily(familyId: string): Promise<void> {
  // Scan for all tokens in this family and mark them invalid.
  // In production, maintain a separate family index for O(1) invalidation.
  // Here: use a family key that clients check, avoiding a full scan.
  await redis.setEx(
    `family:invalidated:${familyId}`,
    7 * 24 * 60 * 60,
    '1'
  );
}

// Refresh endpoint
async function refreshHandler(req: Request, res: Response): Promise<void> {
  const incomingToken = req.cookies?.refresh_token;

  if (!incomingToken) {
    res.status(401).json({ error: 'No refresh token' });
    return;
  }

  const newTokens = await rotateRefreshToken(incomingToken);

  if (!newTokens) {
    // Clear cookies on failure — client must re-authenticate.
    res.clearCookie('access_token');
    res.clearCookie('refresh_token', { path: '/api/auth/refresh' });
    res.status(401).json({ error: 'Invalid or expired refresh token' });
    return;
  }

  // Set new tokens as httpOnly cookies.
  res.cookie('access_token', newTokens.accessToken, {
    httpOnly: true, secure: true, sameSite: 'strict',
    maxAge: 15 * 60 * 1000,
  });
  res.cookie('refresh_token', newTokens.refreshToken, {
    httpOnly: true, secure: true, sameSite: 'strict',
    maxAge: 7 * 24 * 60 * 60 * 1000,
    path: '/api/auth/refresh',
  });

  res.json({ success: true });
}
Comparison visual
flowchart TD A[Client sends refresh_token] --> B{Token exists in Redis?} B -- No --> C[Return 401 - expired or invalid] B -- Yes --> D{isValid == true?} D -- No --> E[THEFT DETECTED] E --> F[Invalidate entire token family] F --> G[Return 401 - force re-login] D -- Yes --> H{Token expired?} H -- Yes --> I[Return 401 - normal expiry] H -- No --> J[Mark old token isValid = false] J --> K[Issue new access_token + refresh_token in same family] K --> L[Return new tokens as httpOnly cookies] L --> M[Client continues authenticated]

4. Session Management

Server-Side Sessions vs JWTs: The Trade-off

The debate between server-side sessions and JWTs is often framed as "stateful vs stateless" but that framing misses the point. The real question is: how quickly do you need to revoke sessions, and can you absorb the latency of a database lookup per request?

Dimension Server-Side Sessions JWTs (Stateless)
Revocation Immediate Requires token rotation or blocklist
Scalability Requires shared session store (Redis) Any server can verify without DB
Per-request latency +1 Redis lookup (~0.5ms local) No extra lookup
Audit visibility Full session metadata in store Claims only
Concurrent session limiting Native Requires server-side tracking
Logout granularity Per-device possible Requires refresh token DB

For most applications, the correct answer is "both": JWTs for stateless API authentication (with short expiry), server-side sessions for the web authentication layer and admin interfaces where immediate revocation is required.

Session Fixation

Session fixation attacks work like this: an attacker obtains a session ID (by reading it from a URL, guessing it, or setting it via a subdomain cookie attack), tricks the victim into authenticating with that session ID, and then uses the now-authenticated session. The fix is mandatory and simple: always regenerate the session ID on privilege escalation — login, password change, MFA verification, role elevation.

If you are using express-session, call req.session.regenerate() after successful authentication. Failing to do this is a critical vulnerability that is trivially exploitable.

Redis Session Store with Concurrent Session Limiting

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();

// Configure session middleware with Redis store.
const sessionMiddleware = session({
  store: new RedisStore({
    client: redisClient,
    prefix: 'sess:',      // Namespace in Redis
    ttl: 86400,           // 24 hours in seconds (server-side TTL)
  }),
  secret: process.env.SESSION_SECRET!, // 32+ byte random value
  resave: false,          // Do not re-save unchanged sessions
  saveUninitialized: false, // Do not create sessions for unauthenticated requests
  cookie: {
    httpOnly: true,       // Not accessible to JavaScript
    secure: true,         // HTTPS only — set to false in dev
    sameSite: 'strict',   // CSRF protection
    maxAge: 24 * 60 * 60 * 1000, // 24 hours client-side
  },
  name: '__Host-session',  // __Host- prefix requires Secure + no Domain + Path=/
                           // Prevents subdomain cookie injection attacks
});

const MAX_SESSIONS_PER_USER = 5; // Maximum concurrent devices

async function loginHandler(req: Request, res: Response): Promise<void> {
  const { username, password } = req.body;

  const user = await validateCredentials(username, password);
  if (!user) {
    // Rate limiting should be applied before this point.
    // Same error message for invalid username and invalid password —
    // prevents username enumeration.
    res.status(401).json({ error: 'Invalid credentials' });
    return;
  }

  // CRITICAL: Regenerate session ID after authentication.
  // This prevents session fixation attacks.
  await new Promise<void>((resolve, reject) => {
    req.session.regenerate((err) => {
      if (err) reject(err);
      else resolve();
    });
  });

  // Enforce concurrent session limit: track all session IDs per user.
  const userSessionsKey = `user_sessions:${user.id}`;
  const existingSessions = await redisClient.lRange(userSessionsKey, 0, -1);

  if (existingSessions.length >= MAX_SESSIONS_PER_USER) {
    // Evict the oldest session (FIFO).
    const oldestSessionId = existingSessions[0];
    await redisClient.del(`sess:${oldestSessionId}`);
    await redisClient.lPop(userSessionsKey);
  }

  // Register this session for the user.
  await redisClient.rPush(userSessionsKey, req.session.id);
  await redisClient.expire(userSessionsKey, 7 * 24 * 60 * 60);

  // Store user info in session — not sensitive data, just what you need for auth.
  req.session.userId = user.id;
  req.session.userRole = user.role;
  req.session.loginAt = Date.now();
  req.session.deviceInfo = req.headers['user-agent']?.slice(0, 100);

  res.json({ success: true });
}

async function logoutHandler(req: Request, res: Response): Promise<void> {
  const userId = req.session.userId;
  const sessionId = req.session.id;

  // Remove session from user's session list.
  if (userId) {
    await redisClient.lRem(`user_sessions:${userId}`, 0, sessionId);
  }

  // Destroy the session in Redis.
  await new Promise<void>((resolve, reject) => {
    req.session.destroy((err) => {
      if (err) reject(err);
      else resolve();
    });
  });

  res.clearCookie('__Host-session');
  res.json({ success: true });
}

// On password change: invalidate all other sessions.
async function invalidateOtherSessions(userId: string, currentSessionId: string): Promise<void> {
  const userSessionsKey = `user_sessions:${userId}`;
  const allSessions = await redisClient.lRange(userSessionsKey, 0, -1);

  for (const sessionId of allSessions) {
    if (sessionId !== currentSessionId) {
      await redisClient.del(`sess:${sessionId}`);
    }
  }

  // Replace the list with only the current session.
  await redisClient.del(userSessionsKey);
  await redisClient.rPush(userSessionsKey, currentSessionId);
  await redisClient.expire(userSessionsKey, 7 * 24 * 60 * 60);
}

5. Passkeys and WebAuthn in 2026

What Passkeys Actually Are

A passkey is a FIDO2/WebAuthn credential stored in a platform authenticator — the device's secure enclave (Secure Enclave on Apple, TPM on Windows, StrongBox on Android). The credential consists of a private key that never leaves the secure enclave and a public key registered with the relying party (your server).

Authentication works via challenge-response: your server sends a random challenge, the authenticator signs it with the private key, and your server verifies the signature against the stored public key. There is no password, no shared secret, and no phishable information — the credential is cryptographically bound to your origin (rpId). A fake site at evil.example.com cannot trigger a passkey registered for example.com.

The Adoption Reality in 2026

Passkeys have crossed the mainstream threshold for consumer applications. Google, Apple, Microsoft, and GitHub all support passkeys as primary authentication. iCloud Keychain and Google Password Manager sync passkeys across devices, solving the "what if I get a new phone" problem that plagued hardware keys.

Enterprise adoption is behind. SSO via SAML/OIDC still dominates enterprise identity. Passkeys are gaining ground as a second factor (replacing TOTP) and for developer tooling, but full passwordless passkey authentication in enterprises is a 2027-2028 story.

For public-facing consumer applications built in 2026, passkeys should be your primary authentication target with password as the fallback for users who have not set up a passkey yet.

Complete WebAuthn Implementation with SimpleWebAuthn

import {
  generateRegistrationOptions,
  verifyRegistrationResponse,
  generateAuthenticationOptions,
  verifyAuthenticationResponse,
  type VerifiedRegistrationResponse,
} from '@simplewebauthn/server';
import type {
  RegistrationResponseJSON,
  AuthenticationResponseJSON,
} from '@simplewebauthn/types';

// Relying Party configuration — must match your domain exactly.
// Any mismatch and the authenticator will refuse to sign.
const RP_NAME = 'Example App';
const RP_ID = 'example.com'; // Must be the effective domain of the origin
const ORIGIN = 'https://example.com'; // Full origin including protocol

// === REGISTRATION ===

async function startRegistration(req: Request, res: Response): Promise<void> {
  const userId = req.session.userId;
  if (!userId) {
    res.status(401).json({ error: 'Not authenticated' });
    return;
  }

  const user = await getUserById(userId);

  // Get any existing credentials for this user (to exclude from re-registration).
  const existingCredentials = await getCredentialsByUserId(userId);

  const options = await generateRegistrationOptions({
    rpName: RP_NAME,
    rpID: RP_ID,
    // User ID must be a Uint8Array — use a stable hash of the user's DB ID.
    userID: new TextEncoder().encode(userId),
    userName: user.email,
    userDisplayName: user.displayName,
    // Exclude existing credentials so the user is not prompted to re-register
    // an already-registered authenticator.
    excludeCredentials: existingCredentials.map(cred => ({
      id: cred.credentialId,
      transports: cred.transports,
    })),
    // Require user verification (biometric or PIN) — not just device presence.
    // This is the difference between "passkey" (UV required) and a security key tap.
    authenticatorSelection: {
      userVerification: 'required',
      residentKey: 'required', // Resident key = discoverable credential = passkey
    },
    // Supported public key algorithms. ES256 (-7) is universal; RS256 (-257) for TPMs.
    supportedAlgorithmIDs: [-7, -257],
  });

  // Store the challenge for verification (ties response to this request).
  // Store in the session — not in a cookie the client can manipulate.
  req.session.registrationChallenge = options.challenge;

  res.json(options);
}

async function completeRegistration(req: Request, res: Response): Promise<void> {
  const userId = req.session.userId;
  const expectedChallenge = req.session.registrationChallenge;

  if (!userId || !expectedChallenge) {
    res.status(400).json({ error: 'No pending registration' });
    return;
  }

  const body: RegistrationResponseJSON = req.body;

  let verification: VerifiedRegistrationResponse;
  try {
    verification = await verifyRegistrationResponse({
      response: body,
      expectedChallenge,
      expectedOrigin: ORIGIN,
      expectedRPID: RP_ID,
      // Require user verification — ensures biometric/PIN was used.
      requireUserVerification: true,
    });
  } catch (error) {
    res.status(400).json({ error: 'Registration verification failed' });
    return;
  }

  if (!verification.verified || !verification.registrationInfo) {
    res.status(400).json({ error: 'Registration not verified' });
    return;
  }

  const { credential, credentialDeviceType, credentialBackedUp } =
    verification.registrationInfo;

  // Store the credential. credentialBackedUp indicates it is a synced passkey
  // (stored in iCloud Keychain / Google Password Manager) vs device-bound.
  await saveCredential({
    userId,
    credentialId: credential.id,
    publicKey: credential.publicKey,     // COSE-encoded public key
    counter: credential.counter,          // For cloned authenticator detection
    transports: credential.transports,
    deviceType: credentialDeviceType,
    backedUp: credentialBackedUp,         // true = synced passkey, false = device-bound
    createdAt: new Date(),
  });

  // Clear the challenge from the session.
  delete req.session.registrationChallenge;

  res.json({ verified: true });
}

// === AUTHENTICATION ===

async function startAuthentication(req: Request, res: Response): Promise<void> {
  // For discoverable credentials (passkeys), userId is optional —
  // the authenticator selects the matching credential itself.
  const options = await generateAuthenticationOptions({
    rpID: RP_ID,
    userVerification: 'required',
    // Do not pass allowCredentials for passkeys — let the authenticator
    // select from stored resident credentials.
  });

  req.session.authenticationChallenge = options.challenge;
  res.json(options);
}

async function completeAuthentication(req: Request, res: Response): Promise<void> {
  const expectedChallenge = req.session.authenticationChallenge;
  if (!expectedChallenge) {
    res.status(400).json({ error: 'No pending authentication' });
    return;
  }

  const body: AuthenticationResponseJSON = req.body;

  // Look up the credential by ID.
  const credential = await getCredentialById(body.id);
  if (!credential) {
    res.status(401).json({ error: 'Unknown credential' });
    return;
  }

  let verification;
  try {
    verification = await verifyAuthenticationResponse({
      response: body,
      expectedChallenge,
      expectedOrigin: ORIGIN,
      expectedRPID: RP_ID,
      credential: {
        id: credential.credentialId,
        publicKey: credential.publicKey,
        counter: credential.counter,         // Previous counter value
        transports: credential.transports,
      },
      requireUserVerification: true,
    });
  } catch (error) {
    res.status(401).json({ error: 'Authentication failed' });
    return;
  }

  if (!verification.verified) {
    res.status(401).json({ error: 'Authentication not verified' });
    return;
  }

  // Update the counter. SimpleWebAuthn verifies that the new counter
  // is greater than the stored counter — this detects cloned authenticators.
  await updateCredentialCounter(credential.credentialId, verification.authenticationInfo.newCounter);

  // Establish session — regenerate ID first (session fixation protection).
  await new Promise<void>((resolve, reject) => {
    req.session.regenerate((err) => { if (err) reject(err); else resolve(); });
  });
  req.session.userId = credential.userId;
  req.session.authMethod = 'passkey';
  req.session.loginAt = Date.now();

  delete req.session.authenticationChallenge;

  res.json({ verified: true });
}

6. Production Checklist

Rate Limiting Login and Token Endpoints

Authentication endpoints are the primary target for credential stuffing and brute force. A sliding window rate limiter per IP and per username is the minimum. The per-username limit catches distributed attacks from many IPs against one account. The per-IP limit catches single-IP attacks against many accounts.

import { RateLimiterRedis } from 'rate-limiter-flexible';

// Two-dimensional rate limiting: per IP and per username.
// Both must pass for a request to proceed.
const rateLimiterByIP = new RateLimiterRedis({
  storeClient: redisClient,
  keyPrefix: 'rl_ip',
  points: 10,          // 10 attempts
  duration: 60,        // per 60 seconds (sliding window)
  blockDuration: 300,  // block for 5 minutes on violation
});

const rateLimiterByUsername = new RateLimiterRedis({
  storeClient: redisClient,
  keyPrefix: 'rl_user',
  points: 5,           // 5 attempts per username
  duration: 300,       // per 5 minutes
  blockDuration: 900,  // 15-minute block on violation
});

async function loginRateLimitMiddleware(
  req: Request,
  res: Response,
  next: NextFunction
): Promise<void> {
  const ip = req.ip!;
  const username = (req.body.username || req.body.email || '').toLowerCase();

  try {
    await Promise.all([
      rateLimiterByIP.consume(ip),
      username ? rateLimiterByUsername.consume(username) : Promise.resolve(),
    ]);
    next();
  } catch {
    // Return Retry-After header so clients can back off gracefully.
    res.set('Retry-After', '60');
    res.status(429).json({ error: 'Too many attempts. Please try again later.' });
  }
}

Account Lockout vs CAPTCHA

Hard account lockout (lock after N failures) is a denial-of-service vector. An attacker who knows your username format can lock out every account with a single request per account. Prefer rate limiting (exponential backoff / sliding window) over lockout. If you must use lockout, pair it with a one-click unlock via email to avoid locking legitimate users out indefinitely.

CAPTCHA as a replacement for lockout is imperfect (Turk armies and ML-based solvers exist) but better than hard lockout. Use CAPTCHA at the point of rate limit violation rather than on every login.

Credential Stuffing Defense

Credential stuffing attacks replay username/password pairs from breached databases. Integration with the HaveIBeenPwned (HIBP) Pwned Passwords API lets you reject passwords known to be compromised — both at registration and at password change. The API uses a k-anonymity model (you send the first 5 characters of the SHA-1 hash, receive matching hashes back), so you never send the actual password to a third party.

Audit Logging Every Auth Event

Every authentication event must be logged with sufficient context to reconstruct a breach timeline: timestamp, user ID, event type (login, logout, token refresh, failed attempt, password change, MFA enroll, passkey register), IP address, user agent, success/failure, and failure reason. These logs should go to an immutable append-only store (CloudTrail, a write-once S3 bucket, or a SIEM) — not just application logs that can be rotated or modified.

MFA: TOTP Implementation

Time-based One-Time Passwords (RFC 6238) use HMAC-SHA1 with a shared secret and a 30-second time window. The security model: even if an attacker has the password, they cannot authenticate without access to the TOTP device.

import * as OTPAuth from 'otpauth';

function generateTOTPSecret(userEmail: string): { secret: string; uri: string } {
  const totp = new OTPAuth.TOTP({
    issuer: 'Example App',
    label: userEmail,
    algorithm: 'SHA1',
    digits: 6,
    period: 30,
    // Generate a 20-byte (160-bit) secret — minimum for RFC 6238 compliance.
    secret: OTPAuth.Secret.generate(20),
  });

  return {
    secret: totp.secret.base32,  // Store this (encrypted) in the database
    uri: totp.toString(),         // Display as QR code for authenticator app enrollment
  };
}

function validateTOTP(secret: string, token: string): boolean {
  const totp = new OTPAuth.TOTP({
    algorithm: 'SHA1',
    digits: 6,
    period: 30,
    secret: OTPAuth.Secret.fromBase32(secret),
  });

  // window: 1 allows the previous and next 30-second period.
  // This handles clock skew without creating a large replay window.
  const delta = totp.validate({ token, window: 1 });

  // delta is null if invalid, 0 if current period, ±1 if adjacent period.
  return delta !== null;
}

Backup codes should be pre-generated (8-10 single-use codes), hashed with bcrypt before storage, and delivered to the user once during MFA enrollment. Treat them like passwords — they are recovery credentials.


Conclusion

The 2026 authentication stack is well-defined. The principles have stabilized: PKCE everywhere for OAuth clients, passkeys for consumer authentication, short-lived JWTs with rotating refresh tokens for API access, and Redis-backed server-side sessions where immediate revocation is a requirement.

The vulnerabilities are also well-documented: algorithm confusion in JWT verification, implicit flow token leakage, localStorage exposure, missing audience validation, and session fixation on privilege escalation. These are not new findings — they are known patterns that still appear in production systems because teams copy examples that do not implement the full security context.

The code in this post covers each layer completely. JWT middleware that asserts algorithm, issuer, audience, and expiry. PKCE implementation with a proper back-channel token exchange. Refresh token rotation with family-based theft detection. Redis session management with concurrent session limiting and session fixation protection. WebAuthn registration and authentication with SimpleWebAuthn, including counter validation for cloned authenticator detection.

Start with the PKCE flow if you are implementing OAuth. Add refresh token rotation immediately — the incremental complexity is low and the protection against token theft is significant. Evaluate passkeys for your user population: if your users are on modern devices (iPhone, Android, Windows Hello), passkeys are production-ready today. And instrument every auth event from day one — you cannot investigate a breach without the log trail.


Sources

About the Author

Toc Am

Founder of AmtocSoft. Writing practical deep-dives on AI engineering, cloud architecture, and developer tooling. Previously built backend systems at scale. Reviews every post published under this byline.

LinkedIn X / Twitter

Published: 2026-06-16 · Updated: 2026-04-18 · Written with AI assistance, reviewed by Toc Am.

Get These In Your Inbox

Weekly deep-dives on AI engineering, no fluff. Join the newsletter →

Subscribe (free)

Or grab the book ($39, ~100 pages) · Buy me a coffee

Buy Me a Coffee · 🔔 YouTube · 💼 LinkedIn · 🐦 X/Twitter

Tuesday, April 7, 2026

OAuth 2.1 and API Authentication Best Practices for 2026

OAuth 2.1 and API Authentication Best Practices — Hero

Introduction

OAuth 2.0 was published in 2012. In the fourteen years since, the security landscape has changed so dramatically that three of its original grant types are now considered dangerous. Implicit grant, which skipped the authorization code exchange and put tokens directly in browser URLs, was a pragmatic shortcut for single-page applications that couldn't keep secrets. Resource Owner Password Credentials (ROPC), which asked users to hand their passwords directly to third-party apps, was a bridge for legacy systems migrating from Basic Auth. Both were reasonable compromises at the time. Both are attack vectors today.

OAuth 2.1 is not a revolution. It is a consolidation. The IETF took the best practices that emerged from years of security research, incident postmortems, and RFC extensions — PKCE, refresh token rotation, stricter redirect URI matching — and folded them into a single specification that replaces OAuth 2.0. If you've been following security best practices, you're probably already doing most of what OAuth 2.1 requires. If you haven't, this is your wake-up call.

The timing matters because 2026 is the year machine-to-machine authentication overtook human-to-service authentication in volume. AI agents, microservices, CI/CD pipelines, and IoT devices now generate more API traffic than browser-based users. The Client Credentials flow — the grant type designed for machines — has become the most important flow to get right. And the patterns for securing tokens, rotating credentials, and validating claims have evolved significantly since the last time most teams reviewed their auth stack.

This post walks through everything an intermediate developer needs to understand and implement OAuth 2.1 correctly. We'll cover what changed from 2.0 and why, break down PKCE so it actually makes sense, implement production-ready auth in both Python (FastAPI) and TypeScript (Node.js), compare JWT against opaque tokens with a clear decision framework, and catalog the security anti-patterns that still plague production systems. By the end, you'll have both the conceptual understanding and the working code to secure your APIs properly.

The Problem: Broken Auth Patterns That Won't Die

API Authentication Threat Landscape — Architecture Diagram

Every major API breach in the past three years traces back to one of a handful of authentication failures. Not exotic zero-days. Not nation-state tooling. Broken auth. The kind of vulnerabilities that exist because teams copied a tutorial from 2016 and never revisited their implementation.

The Implicit Grant Disaster

The implicit grant was designed for browser-based JavaScript applications that couldn't securely store a client secret. Instead of exchanging an authorization code for tokens at a token endpoint, the authorization server returned the access token directly in the URL fragment. The logic was simple: the fragment isn't sent to the server, so it's "safe enough."

It wasn't. Access tokens in URL fragments get logged in browser history, leaked via the Referer header, captured by browser extensions, and exposed to any JavaScript running on the page. A single XSS vulnerability turns implicit grant tokens into a credential-harvesting bonanza. The token has no binding to the client that requested it, so a stolen token works anywhere. There is no refresh token, so access tokens must be long-lived — widening the attack window from minutes to hours.

OAuth 2.1 removes the implicit grant entirely. Every client, including single-page applications, must use the authorization code flow with PKCE.

Resource Owner Password Credentials: Trust Nobody

ROPC asked users to type their username and password directly into the third-party application, which then sent those credentials to the authorization server. This required users to trust that the application wouldn't store, leak, or misuse their raw credentials. It trained users to enter passwords into apps that aren't the identity provider — the exact behavior that phishing attacks exploit.

OAuth 2.1 removes ROPC entirely. There is no legitimate use case for a third-party application to handle user passwords in 2026.

Token Sprawl and Leaked Secrets

Beyond deprecated flows, production systems suffer from endemic token management failures. Long-lived API keys committed to Git repositories. Refresh tokens stored in localStorage where any XSS payload can exfiltrate them. Access tokens with 24-hour lifetimes because "the refresh logic was too complicated." Service accounts with wildcard permissions because scoping was "too much work for the sprint."

A 2025 study of GitHub public repositories found over 12 million leaked API credentials — tokens, keys, and secrets exposed in source code. Secret scanning helps after the fact, but the root problem is architectural: teams treat authentication as a configuration checkbox rather than a security boundary.

The Machine Identity Gap

The fastest-growing attack surface is machine-to-machine authentication. Microservices calling microservices. AI agents calling APIs. Pipelines calling deployment targets. Most teams handle this with static API keys or long-lived service account tokens — the exact pattern that turns a single compromised service into lateral movement across the entire system.

OAuth 2.1's Client Credentials flow, combined with short-lived tokens and certificate-bound credentials, addresses this gap. But only if you implement it correctly.

graph TD A[OAuth 2.0 Flows] --> B{Which flow?} B -->|Implicit Grant| C[Token in URL Fragment] C --> D[XSS Exposure] C --> E[Browser History Leak] C --> F[Referer Header Leak] D --> G[TOKEN COMPROMISED] E --> G F --> G B -->|ROPC| H[Password in App] H --> I[Phishing Training] H --> J[Credential Storage Risk] I --> K[CREDENTIALS COMPROMISED] J --> K B -->|Auth Code + PKCE| L[Secure Exchange] L --> M[Short-lived Tokens] M --> N[Refresh Rotation] N --> O[SECURE] style C fill:#ef4444,stroke:#dc2626,color:#fff style H fill:#ef4444,stroke:#dc2626,color:#fff style G fill:#7f1d1d,stroke:#991b1b,color:#fff style K fill:#7f1d1d,stroke:#991b1b,color:#fff style L fill:#22c55e,stroke:#16a34a,color:#fff style O fill:#14532d,stroke:#166534,color:#fff

Figure 1: OAuth 2.0's deprecated flows (implicit grant and ROPC) create multiple attack vectors. OAuth 2.1 mandates the authorization code flow with PKCE for all clients.

How It Works: OAuth 2.1 Core Changes and the PKCE Flow

OAuth 2.1 doesn't introduce new grant types. It removes dangerous ones, mandates security extensions that were optional in 2.0, and tightens the rules for everything that remains. Here are the key changes.

What OAuth 2.1 Removes

Implicit grant is gone. No more response_type=token. All clients use the authorization code flow, including single-page applications and mobile apps. If your SPA currently uses implicit grant, migration is mandatory.

Resource Owner Password Credentials is gone. No more sending usernames and passwords through third-party applications. First-party apps that need password-based login should use the authorization code flow with a first-party authorization server.

What OAuth 2.1 Mandates

PKCE is required for every authorization code flow, not just public clients. In OAuth 2.0, PKCE was recommended for mobile and SPA clients but optional for confidential (server-side) clients. OAuth 2.1 requires PKCE universally. This protects against authorization code interception attacks even for server-side applications.

Exact redirect URI matching replaces pattern matching. In OAuth 2.0, some authorization servers allowed wildcard or prefix matching on redirect URIs (e.g., https://app.example.com/*). OAuth 2.1 requires exact string matching. The redirect URI in the authorization request must exactly match one of the registered redirect URIs for the client.

Refresh token rotation or sender-constraining is required. Every time a refresh token is used, the authorization server must either issue a new refresh token (rotation) or bind the refresh token to the client via mTLS or DPoP (sender-constraining). Stolen refresh tokens can't be replayed indefinitely.

PKCE: How It Actually Works

PKCE (Proof Key for Code Exchange, pronounced "pixie") solves a specific problem: what happens if an attacker intercepts the authorization code during the redirect back to the client? Without PKCE, the attacker can exchange that code for tokens at the token endpoint. With PKCE, only the client that initiated the request can complete the exchange.

Here's the flow step by step:

Step 1: Client generates a random secret. The client creates a cryptographically random string called the code_verifier. This is a high-entropy string between 43 and 128 characters.

Step 2: Client derives a challenge. The client computes the SHA-256 hash of the code_verifier and base64url-encodes it. This is the code_challenge.

Step 3: Client sends the challenge with the authorization request. The authorization request includes code_challenge and code_challenge_method=S256. The authorization server stores this challenge.

Step 4: User authenticates and authorization server redirects with a code. Standard OAuth behavior — the user logs in, consents, and the server redirects to the client's redirect URI with an authorization code.

Step 5: Client exchanges the code with the original verifier. The token request includes both the authorization code and the original code_verifier. The authorization server hashes the verifier, compares it to the stored challenge, and only issues tokens if they match.

An attacker who intercepts the authorization code in Step 4 cannot complete Step 5 because they don't have the code_verifier. The code_challenge sent in Step 3 is a one-way hash — you can't reverse it to get the verifier.

sequenceDiagram participant Client participant AuthServer as Authorization Server participant User Note over Client: Generate code_verifier (random) Note over Client: Compute code_challenge = SHA256(code_verifier) Client->>AuthServer: GET /authorize?response_type=code
&code_challenge=abc123
&code_challenge_method=S256
&client_id=...&redirect_uri=...&scope=... AuthServer->>User: Login page User->>AuthServer: Authenticate + consent AuthServer->>Client: Redirect to redirect_uri?code=AUTH_CODE Note over Client: Attacker may intercept AUTH_CODE here
but cannot proceed without code_verifier Client->>AuthServer: POST /token
code=AUTH_CODE
&code_verifier=original_secret
&client_id=...&redirect_uri=... Note over AuthServer: Verify: SHA256(code_verifier) == stored code_challenge AuthServer->>Client: { access_token, refresh_token, expires_in } style Client fill:#3b82f6 style AuthServer fill:#8b5cf6 style User fill:#22c55e

Figure 2: The PKCE authorization code flow. The code_verifier never leaves the client until the token exchange, preventing authorization code interception attacks.

Token Lifecycle in OAuth 2.1

OAuth 2.1 tightens the entire token lifecycle:

Access tokens should be short-lived: 5-15 minutes is the recommended range. Short lifetimes limit the damage window if a token is stolen. The trade-off is more frequent refresh operations, but modern HTTP clients handle this transparently.

Refresh tokens must be rotated or sender-constrained. Rotation means every refresh request returns a new refresh token, and the old one is invalidated. If an attacker steals a refresh token and the legitimate client also uses it, the authorization server detects the reuse and revokes the entire token family.

Token binding (via DPoP or mTLS) ties tokens to a specific client's cryptographic key. Even if a bound token is intercepted, it can't be used from a different machine because the attacker doesn't have the private key. DPoP (Demonstration of Proof-of-Possession) is the most practical binding mechanism for web applications.

Implementation Guide: Production Code in Python and TypeScript

Let's build production-ready OAuth 2.1 implementations. We'll cover the authorization code flow with PKCE for user-facing apps and the Client Credentials flow for machine-to-machine auth.

Python (FastAPI): OAuth 2.1 Authorization Server Middleware

This middleware validates incoming access tokens, supports both JWT and opaque token introspection, and enforces scope-based access control.

# oauth_middleware.py — FastAPI OAuth 2.1 Token Validation
import hashlib
import secrets
import time
from datetime import datetime, timedelta, timezone
from typing import Optional

import httpx
from fastapi import Depends, FastAPI, HTTPException, Request, Security
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt
from pydantic import BaseModel

app = FastAPI()
security = HTTPBearer()

# Configuration — load from environment in production
JWKS_URI = "https://auth.example.com/.well-known/jwks.json"
ISSUER = "https://auth.example.com"
AUDIENCE = "https://api.example.com"
INTROSPECTION_ENDPOINT = "https://auth.example.com/oauth/introspect"
CLIENT_ID = "api-server"
CLIENT_SECRET = "server-secret"  # Use env var in production

# JWKS cache with TTL
_jwks_cache: dict = {"keys": [], "expires_at": 0}


async def get_jwks() -> dict:
    """Fetch and cache JWKS (JSON Web Key Set) from the authorization server."""
    now = time.time()
    if _jwks_cache["expires_at"] > now:
        return _jwks_cache

    async with httpx.AsyncClient() as client:
        response = await client.get(JWKS_URI, timeout=5.0)
        response.raise_for_status()
        keys = response.json()
        _jwks_cache["keys"] = keys.get("keys", [])
        _jwks_cache["expires_at"] = now + 3600  # Cache for 1 hour
        return _jwks_cache


class TokenClaims(BaseModel):
    """Validated token claims available to route handlers."""
    sub: str
    scope: list[str]
    client_id: Optional[str] = None
    exp: int
    iss: str
    aud: str


async def validate_jwt_token(token: str) -> TokenClaims:
    """Validate a JWT access token against the authorization server's JWKS."""
    try:
        jwks = await get_jwks()
        # Decode header to find the key ID (kid)
        unverified_header = jwt.get_unverified_header(token)
        kid = unverified_header.get("kid")

        # Find matching key in JWKS
        rsa_key = None
        for key in jwks["keys"]:
            if key.get("kid") == kid:
                rsa_key = key
                break

        if not rsa_key:
            raise HTTPException(status_code=401, detail="Token signing key not found")

        # Verify token signature, expiration, issuer, and audience
        payload = jwt.decode(
            token,
            rsa_key,
            algorithms=["RS256"],
            audience=AUDIENCE,
            issuer=ISSUER,
            options={"require_exp": True, "require_iss": True, "require_aud": True},
        )

        # Parse scope — OAuth 2.1 uses space-delimited scope string
        scope_str = payload.get("scope", "")
        scopes = scope_str.split() if isinstance(scope_str, str) else scope_str

        return TokenClaims(
            sub=payload["sub"],
            scope=scopes,
            client_id=payload.get("client_id"),
            exp=payload["exp"],
            iss=payload["iss"],
            aud=payload["aud"],
        )

    except JWTError as e:
        raise HTTPException(
            status_code=401,
            detail=f"Invalid token: {str(e)}",
            headers={"WWW-Authenticate": "Bearer"},
        )


async def validate_opaque_token(token: str) -> TokenClaims:
    """Validate an opaque token via the introspection endpoint (RFC 7662)."""
    async with httpx.AsyncClient() as client:
        response = await client.post(
            INTROSPECTION_ENDPOINT,
            data={"token": token, "token_type_hint": "access_token"},
            auth=(CLIENT_ID, CLIENT_SECRET),
            timeout=5.0,
        )
        response.raise_for_status()
        data = response.json()

    if not data.get("active"):
        raise HTTPException(
            status_code=401,
            detail="Token is inactive or revoked",
            headers={"WWW-Authenticate": "Bearer"},
        )

    scope_str = data.get("scope", "")
    scopes = scope_str.split() if isinstance(scope_str, str) else scope_str

    return TokenClaims(
        sub=data["sub"],
        scope=scopes,
        client_id=data.get("client_id"),
        exp=data["exp"],
        iss=data.get("iss", ISSUER),
        aud=data.get("aud", AUDIENCE),
    )


async def get_current_token(
    credentials: HTTPAuthorizationCredentials = Security(security),
) -> TokenClaims:
    """Extract and validate the bearer token from the Authorization header.

    Automatically detects JWT vs opaque tokens. JWTs contain dots (header.payload.signature),
    opaque tokens do not.
    """
    token = credentials.credentials

    if token.count(".") == 2:
        # Looks like a JWT — validate locally using JWKS
        return await validate_jwt_token(token)
    else:
        # Opaque token — validate via introspection
        return await validate_opaque_token(token)


def require_scope(required: str):
    """Dependency that enforces a specific OAuth scope on the endpoint."""
    async def check_scope(claims: TokenClaims = Depends(get_current_token)):
        if required not in claims.scope:
            raise HTTPException(
                status_code=403,
                detail=f"Insufficient scope. Required: {required}",
            )
        return claims
    return check_scope


# --- PKCE Helper: Client-side code verifier and challenge generation ---

def generate_pkce_pair() -> tuple[str, str]:
    """Generate a PKCE code_verifier and code_challenge pair.

    Returns:
        Tuple of (code_verifier, code_challenge) where the challenge
        is the base64url-encoded SHA-256 hash of the verifier.
    """
    # Generate 32 bytes of random data, base64url-encode to get 43 chars
    code_verifier = secrets.token_urlsafe(32)

    # Compute S256 challenge: BASE64URL(SHA256(code_verifier))
    digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
    code_challenge = (
        __import__("base64")
        .urlsafe_b64encode(digest)
        .rstrip(b"=")
        .decode("ascii")
    )

    return code_verifier, code_challenge


# --- Example Protected Routes ---

@app.get("/api/user/profile")
async def get_profile(claims: TokenClaims = Depends(require_scope("profile:read"))):
    """Protected endpoint requiring 'profile:read' scope."""
    return {
        "user_id": claims.sub,
        "scopes": claims.scope,
        "token_expires": datetime.fromtimestamp(claims.exp, tz=timezone.utc).isoformat(),
    }


@app.post("/api/data/export")
async def export_data(claims: TokenClaims = Depends(require_scope("data:export"))):
    """Protected endpoint requiring 'data:export' scope."""
    return {
        "status": "export_started",
        "requested_by": claims.sub,
        "client_id": claims.client_id,
    }

TypeScript (Node.js): Client Credentials Flow for Machine-to-Machine Auth

This implementation handles the Client Credentials flow for service-to-service authentication, with automatic token refresh and retry logic.

// oauth-client.ts — Machine-to-Machine OAuth 2.1 Client
import crypto from "crypto";

interface TokenResponse {
  access_token: string;
  token_type: string;
  expires_in: number;
  scope?: string;
}

interface CachedToken {
  accessToken: string;
  expiresAt: number; // Unix timestamp in milliseconds
  scopes: string[];
}

interface OAuthClientConfig {
  tokenEndpoint: string;
  clientId: string;
  clientSecret: string;
  defaultScopes: string[];
  // Buffer in seconds before expiry to trigger refresh (default: 30)
  refreshBuffer?: number;
}

class OAuthClientCredentials {
  private config: OAuthClientConfig;
  private tokenCache: CachedToken | null = null;
  private pendingRefresh: Promise<CachedToken> | null = null;

  constructor(config: OAuthClientConfig) {
    this.config = config;
  }

  /**
   * Get a valid access token, refreshing if necessary.
   * Uses a single-flight pattern to prevent concurrent token requests.
   */
  async getToken(scopes?: string[]): Promise<string> {
    const requestedScopes = scopes ?? this.config.defaultScopes;

    // Check if cached token is still valid
    if (this.tokenCache && this.isTokenValid(this.tokenCache)) {
      // Verify cached token has all requested scopes
      const hasAllScopes = requestedScopes.every((s) =>
        this.tokenCache!.scopes.includes(s)
      );
      if (hasAllScopes) {
        return this.tokenCache.accessToken;
      }
    }

    // Single-flight: if a refresh is already in progress, wait for it
    if (this.pendingRefresh) {
      const token = await this.pendingRefresh;
      return token.accessToken;
    }

    // Fetch a new token
    this.pendingRefresh = this.fetchToken(requestedScopes);
    try {
      const token = await this.pendingRefresh;
      this.tokenCache = token;
      return token.accessToken;
    } finally {
      this.pendingRefresh = null;
    }
  }

  /**
   * Make an authenticated HTTP request with automatic token management.
   * Retries once on 401 with a fresh token.
   */
  async authenticatedFetch(
    url: string,
    options: RequestInit = {}
  ): Promise<Response> {
    const token = await this.getToken();
    const headers = new Headers(options.headers);
    headers.set("Authorization", `Bearer ${token}`);

    let response = await fetch(url, { ...options, headers });

    // If 401, token might have been revoked — get fresh token and retry once
    if (response.status === 401) {
      this.tokenCache = null; // Invalidate cache
      const freshToken = await this.getToken();
      headers.set("Authorization", `Bearer ${freshToken}`);
      response = await fetch(url, { ...options, headers });
    }

    return response;
  }

  private isTokenValid(token: CachedToken): boolean {
    const bufferMs = (this.config.refreshBuffer ?? 30) * 1000;
    return Date.now() < token.expiresAt - bufferMs;
  }

  private async fetchToken(scopes: string[]): Promise<CachedToken> {
    // Build the token request per OAuth 2.1 Client Credentials flow
    const body = new URLSearchParams({
      grant_type: "client_credentials",
      scope: scopes.join(" "),
    });

    // Client authentication via HTTP Basic (client_id:client_secret)
    const credentials = Buffer.from(
      `${this.config.clientId}:${this.config.clientSecret}`
    ).toString("base64");

    const response = await fetch(this.config.tokenEndpoint, {
      method: "POST",
      headers: {
        "Content-Type": "application/x-www-form-urlencoded",
        Authorization: `Basic ${credentials}`,
      },
      body: body.toString(),
    });

    if (!response.ok) {
      const errorBody = await response.text();
      throw new Error(
        `Token request failed (${response.status}): ${errorBody}`
      );
    }

    const data: TokenResponse = await response.json();

    return {
      accessToken: data.access_token,
      expiresAt: Date.now() + data.expires_in * 1000,
      scopes: data.scope?.split(" ") ?? scopes,
    };
  }
}

// --- PKCE Utilities for Browser/Mobile Clients ---

function generateCodeVerifier(): string {
  // 32 bytes of random data, base64url-encoded
  const buffer = crypto.randomBytes(32);
  return buffer
    .toString("base64")
    .replace(/\+/g, "-")
    .replace(/\//g, "_")
    .replace(/=/g, "");
}

async function generateCodeChallenge(verifier: string): Promise<string> {
  const encoder = new TextEncoder();
  const data = encoder.encode(verifier);
  const digest = await crypto.subtle.digest("SHA-256", data);
  return Buffer.from(digest)
    .toString("base64")
    .replace(/\+/g, "-")
    .replace(/\//g, "_")
    .replace(/=/g, "");
}

// --- Usage Example ---

async function main() {
  // Machine-to-machine: service calling another service
  const authClient = new OAuthClientCredentials({
    tokenEndpoint: "https://auth.example.com/oauth/token",
    clientId: "data-pipeline-service",
    clientSecret: process.env.OAUTH_CLIENT_SECRET!,
    defaultScopes: ["data:read", "data:write"],
    refreshBuffer: 60, // Refresh 60 seconds before expiry
  });

  // Automatic token management — get token, cache, refresh transparently
  const response = await authClient.authenticatedFetch(
    "https://api.example.com/v1/data/process",
    {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ pipeline: "daily-etl", batch_size: 1000 }),
    }
  );

  console.log("API response:", response.status, await response.json());

  // PKCE for user-facing auth flows
  const codeVerifier = generateCodeVerifier();
  const codeChallenge = await generateCodeChallenge(codeVerifier);
  console.log("PKCE verifier:", codeVerifier);
  console.log("PKCE challenge:", codeChallenge);
}

main().catch(console.error);

Refresh Token Rotation with Replay Detection

Here is a standalone FastAPI endpoint that demonstrates refresh token rotation with replay detection — critical for preventing stolen refresh tokens from being reused.

# refresh_rotation.py — Refresh Token Rotation with Replay Detection
import secrets
import time
from dataclasses import dataclass, field

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

app = FastAPI()


@dataclass
class TokenFamily:
    """Tracks a chain of refresh tokens from a single authorization grant.

    If any token in the family is reused after rotation, the entire
    family is revoked — this detects token theft.
    """
    family_id: str
    current_token_hash: str
    user_id: str
    scopes: list[str]
    created_at: float
    rotated_at: float
    used_tokens: set[str] = field(default_factory=set)  # Hashes of already-rotated tokens
    revoked: bool = False


# In production, use Redis or a database — not an in-memory dict
token_families: dict[str, TokenFamily] = {}  # family_id -> TokenFamily
token_to_family: dict[str, str] = {}  # token_hash -> family_id


def hash_token(token: str) -> str:
    """Hash a refresh token for storage. Never store raw refresh tokens."""
    import hashlib
    return hashlib.sha256(token.encode()).hexdigest()


class RefreshRequest(BaseModel):
    refresh_token: str
    client_id: str


class TokenPair(BaseModel):
    access_token: str
    refresh_token: str
    token_type: str = "Bearer"
    expires_in: int = 900  # 15 minutes


@app.post("/oauth/token/refresh", response_model=TokenPair)
async def refresh_token(request: RefreshRequest) -> TokenPair:
    """Exchange a refresh token for a new access + refresh token pair.

    Implements refresh token rotation per OAuth 2.1:
    1. Each refresh generates a new refresh token
    2. The old refresh token is invalidated
    3. If a previously-rotated token is reused, the ENTIRE family is revoked
       (indicates the token was stolen and both parties are trying to use it)
    """
    token_hash = hash_token(request.refresh_token)

    # Look up which token family this refresh token belongs to
    family_id = token_to_family.get(token_hash)
    if not family_id:
        raise HTTPException(status_code=401, detail="Invalid refresh token")

    family = token_families.get(family_id)
    if not family:
        raise HTTPException(status_code=401, detail="Token family not found")

    # Check if family is already revoked
    if family.revoked:
        raise HTTPException(
            status_code=401,
            detail="Token family revoked due to suspected theft. Re-authenticate.",
        )

    # REPLAY DETECTION: Is this a previously-used token?
    if token_hash in family.used_tokens:
        # This token was already rotated — someone is replaying it.
        # Revoke the entire family to protect the user.
        family.revoked = True
        raise HTTPException(
            status_code=401,
            detail="Refresh token reuse detected. All tokens revoked. Re-authenticate.",
        )

    # Verify this is the current (most recent) refresh token
    if token_hash != family.current_token_hash:
        # Not the current token and not in used_tokens — shouldn't happen
        family.revoked = True
        raise HTTPException(status_code=401, detail="Token family compromised")

    # --- Rotation: issue new tokens ---

    # Move current token to used set
    family.used_tokens.add(token_hash)

    # Generate new refresh token
    new_refresh_token = secrets.token_urlsafe(48)
    new_token_hash = hash_token(new_refresh_token)

    # Update family
    family.current_token_hash = new_token_hash
    family.rotated_at = time.time()

    # Update lookup index
    token_to_family[new_token_hash] = family_id
    # Optionally remove old mapping after a grace period (not immediately,
    # in case of network retries)

    # Generate new access token (in production, sign a JWT)
    new_access_token = secrets.token_urlsafe(32)

    return TokenPair(
        access_token=new_access_token,
        refresh_token=new_refresh_token,
    )

Comparison and Tradeoffs: JWT vs Opaque Tokens, API Keys vs OAuth

Choosing the right token format and authentication mechanism is one of the most consequential architectural decisions in API design. There is no universal best choice — the right answer depends on your system's specific requirements.

Authentication Patterns Comparison — Visual

JWT vs Opaque Tokens

Aspect JWT (Self-contained) Opaque Tokens (Reference)
Validation Local — verify signature and claims Remote — call introspection endpoint
Latency No network call needed Adds 1-5ms per request for introspection
Revocation Difficult — token is valid until expiry Immediate — delete from server store
Size 800-2000+ bytes (grows with claims) 32-48 bytes (fixed)
Privacy Claims visible to anyone with the token Claims only visible to authorization server
Scalability Excellent — no shared state Requires fast introspection backend (Redis)
Debugging Easy — decode at jwt.io Need server access to inspect
Best for Microservices, distributed systems User-facing apps needing instant revocation

Use JWTs when you need stateless validation at scale across many services and can tolerate the revocation delay (keep access token lifetime under 15 minutes). JWTs shine in microservice architectures where dozens of services need to validate tokens independently.

Use opaque tokens when you need instant revocation (financial services, healthcare), want to keep claims private, or have a centralized API gateway that can handle introspection efficiently.

API Keys vs OAuth

API keys are not an authentication standard. They are shared secrets. Treating them as equivalent to OAuth is a category error that persists across the industry.

Aspect API Keys OAuth 2.1 Client Credentials
Rotation Manual — requires app redeployment Automatic — short-lived tokens
Scope Typically all-or-nothing Fine-grained per-request scopes
Revocation Regenerate key, update all clients Revoke grant, tokens expire naturally
Audit trail Key identified, not action context Full token introspection with metadata
Credential exposure Single static secret in config Client secret exchanges for ephemeral tokens
Best for Rate limiting, usage tracking Authentication and authorization

Use API keys for identifying callers (metering, rate limiting, analytics). API keys answer "who is calling?" but should not answer "what are they allowed to do?"

Use OAuth Client Credentials for authorizing actions. Short-lived tokens, scoped permissions, and automatic rotation make this the correct choice for machine-to-machine authentication.

mTLS for High-Security Environments

Mutual TLS (mTLS) adds certificate-based client authentication at the transport layer. The client presents a certificate during the TLS handshake, and the server verifies it against a trusted CA. This provides the strongest client authentication but requires certificate management infrastructure (issuance, rotation, revocation lists).

Use mTLS when operating in zero-trust environments, securing service meshes, or meeting regulatory requirements (PCI-DSS, SOC 2). Combine with OAuth for defense in depth: mTLS authenticates the transport, OAuth authorizes the action.

graph LR subgraph "Choose Your Auth Pattern" A{What are you
authenticating?} -->|Human User| B{Client type?} A -->|Machine / Service| C{Security level?} A -->|Rate Limiting Only| D[API Key] B -->|Browser SPA| E[Auth Code + PKCE
Short-lived JWT] B -->|Mobile App| F[Auth Code + PKCE
Secure Storage] B -->|Server-side App| G[Auth Code + PKCE
Confidential Client] C -->|Standard| H[Client Credentials
JWT Access Tokens] C -->|High Security| I[Client Credentials
+ mTLS + DPoP] C -->|Internal Mesh| J[mTLS + SPIFFE
Service Identity] end style D fill:#f59e0b,stroke:#d97706,color:#000 style E fill:#3b82f6,stroke:#2563eb,color:#fff style F fill:#3b82f6,stroke:#2563eb,color:#fff style G fill:#3b82f6,stroke:#2563eb,color:#fff style H fill:#22c55e,stroke:#16a34a,color:#fff style I fill:#8b5cf6,stroke:#7c3aed,color:#fff style J fill:#8b5cf6,stroke:#7c3aed,color:#fff

Figure 3: Decision framework for choosing the right authentication pattern. The choice depends on who is authenticating (human vs machine), the client type, and security requirements.

Production Considerations

Token Storage by Client Type

Where you store tokens determines your security posture. There is no single correct answer — it depends on your client architecture.

Server-side applications: Store tokens in server-side sessions (Redis, database). Tokens never reach the browser. This is the most secure option and the reason confidential clients exist.

Single-page applications: Use the Backend-for-Frontend (BFF) pattern. The SPA talks to a thin backend that holds tokens in HTTP-only, Secure, SameSite=Strict cookies. The SPA never directly handles access or refresh tokens. Storing tokens in localStorage or sessionStorage exposes them to XSS attacks.

Mobile applications: Use platform-specific secure storage — iOS Keychain or Android Keystore. These provide hardware-backed encryption that survives app restarts. Never store tokens in SharedPreferences, UserDefaults, or any plaintext storage.

CLI tools and agents: Use the system keychain (macOS Keychain, Windows Credential Manager, Linux Secret Service) via libraries like keyring (Python) or keytar (Node.js). For CI/CD environments, use the platform's secrets manager (GitHub Actions secrets, AWS Secrets Manager).

Monitoring and Alerting

OAuth infrastructure generates high-value security signals. Monitor these actively:

Token issuance rate: A sudden spike in token requests from a single client may indicate credential compromise or a misconfigured retry loop. Set alerts for rates exceeding 10x the baseline.

Refresh token reuse: Any reuse of a rotated refresh token is a confirmed security incident. Alert immediately and revoke the token family.

Scope escalation attempts: Clients requesting scopes beyond their registration should trigger security review. This may indicate a compromised client attempting privilege escalation.

Failed introspection rates: A high rate of invalid tokens hitting your introspection endpoint may indicate a brute-force attack or token spray attack.

Authorization code exchange failures: A high rate of PKCE validation failures on the token endpoint may indicate an active authorization code interception attack.

Scaling the Authorization Server

The authorization server is on the critical path for every authenticated request (directly for opaque tokens, indirectly for JWT key rotation). Plan capacity accordingly:

JWKS endpoint caching: Clients should cache the JWKS for hours, not minutes. Set Cache-Control: max-age=3600 on the JWKS endpoint. Key rotation should overlap — publish the new key before signing with it, retire the old key after all cached copies expire.

Introspection endpoint: If using opaque tokens at scale, the introspection endpoint must handle the same request rate as your entire API surface. Back it with Redis or an in-memory cache with sub-millisecond latency.

Rate limiting on token endpoints: Apply strict rate limits on /token and /authorize endpoints. These are authentication endpoints — high request rates are either attacks or misconfigurations.

Common Mistakes and Anti-Patterns

  1. Long-lived access tokens (>1 hour): If your access tokens live longer than 15 minutes, you're trading security for convenience. Use refresh tokens instead.

  2. Storing tokens in localStorage: XSS attacks can read localStorage. Use the BFF pattern with HTTP-only cookies for browser applications.

  3. Skipping PKCE for confidential clients: OAuth 2.1 requires PKCE universally. Even if your server keeps its client secret safe, PKCE protects against authorization code injection attacks at the authorization endpoint level.

  4. Hardcoded client secrets in source code: Use environment variables, secret managers, or vault services. Rotate secrets on a schedule, not just when they're compromised.

  5. Ignoring token scope: Requesting * or overly broad scopes because "it's easier" violates least privilege. Each client should request the minimum scopes needed for its function.

  6. No refresh token rotation: Refresh tokens without rotation are long-lived credentials. If stolen, they grant indefinite access until manually revoked.

  7. Using API keys as the sole authentication mechanism: API keys identify callers but don't provide the security properties of OAuth (scoped access, expiration, rotation). Use API keys for metering, OAuth for auth.

  8. Validating tokens by calling the authorization server on every request with JWTs: The entire point of JWTs is local validation. If you're calling the authorization server for every JWT, you've built an opaque token system with extra steps. Validate the signature locally.

Conclusion

OAuth 2.1 is not a new standard to learn. It is the formalization of what you should already be doing. PKCE on every authorization code flow. No implicit grant. No ROPC. Refresh token rotation. Short-lived access tokens. Exact redirect URI matching. If any of these are missing from your current implementation, that is the gap to close first.

The decision framework is straightforward: use the authorization code flow with PKCE for human users, Client Credentials for machines, and never use API keys as your sole authentication mechanism. Choose JWTs for distributed validation at scale, opaque tokens when you need instant revocation. Add mTLS when regulations or threat models demand transport-level client authentication.

The code examples in this post are production-ready starting points, not toy demos. The FastAPI middleware handles both JWT and opaque token validation with scope enforcement. The TypeScript client manages the Client Credentials flow with automatic token caching, refresh, and retry logic. The refresh rotation implementation detects token theft through replay detection and revokes the entire token family.

Authentication is not a feature you ship and forget. It is infrastructure that requires ongoing monitoring, rotation, and hardening. Set up alerts for token reuse, scope escalation, and abnormal issuance rates. Review your token lifetimes quarterly. Rotate your client secrets on a schedule. And when OAuth 2.1 is formally ratified — which is expected in late 2026 — you'll already be compliant because you built it right from the start.

If you're building APIs that AI agents consume, revisit our previous post on API Security in the Age of AI Agents and MCP for the agent-specific threat model. Together, these two posts cover the full authentication and authorization landscape for modern API security.

About the Author

Toc Am

Founder of AmtocSoft. Writing practical deep-dives on AI engineering, cloud architecture, and developer tooling. Previously built backend systems at scale. Reviews every post published under this byline.

LinkedIn X / Twitter

Published: 2026-05-06 · Written with AI assistance, reviewed by Toc Am.

Get These In Your Inbox

Weekly deep-dives on AI engineering, no fluff. Join the newsletter →

Subscribe (free)

Or grab the book ($39, ~100 pages) · Buy me a coffee

Buy Me a Coffee · 🔔 YouTube · 💼 LinkedIn · 🐦 X/Twitter

Attention Is All You Need, Explained Simply

We published a plain-language walkthrough of the 2017 transformer paper — queries, keys, values, multi-head attention, and why no-recurrence...