Showing posts with label authentication. Show all posts
Showing posts with label authentication. 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

Identity Is the New Perimeter: Zero Trust for Developers

Hero: Zero Trust Architecture — identity verification at every hop

Introduction

There was a time when building secure software meant building a moat. You put your servers inside a corporate network, slapped a firewall on the edge, and assumed that anything already inside the walls was trustworthy. If a request came from the right IP range, it was probably fine. If it was on the VPN, it was almost certainly fine.

That model made sense when applications ran in a single data center, when developers worked from a single office, and when "the cloud" meant someone else's file cabinet. It does not make sense anymore.

Today's applications are distributed across AWS, GCP, Azure, and edge nodes. Developers connect from home, coffee shops, and co-working spaces. Microservices talk to other microservices across container boundaries that do not map to any physical location. A single user request might touch a dozen internal APIs before a response is assembled. The "inside" of the network is everywhere — and that means the perimeter no longer exists in any meaningful sense.

Zero Trust is the architectural response to this reality. The core principle is blunt: never trust, always verify. Every request — whether it comes from a user's browser, a background job, or a peer microservice — must authenticate, must be authorized for the specific action it is requesting, and must be re-verified continuously. Location on the network grants nothing.

For security architects and compliance teams, Zero Trust is often discussed at the policy and framework level. This post is for developers. We will get into the code: how to validate JWT tokens properly in a FastAPI service, how to configure mTLS between services, how to use SPIFFE/SPIRE to give workloads cryptographic identities, and how to write policy-based authorization using OPA. We will look at what these patterns actually cost in terms of latency and operational overhead. And we will be honest about the tradeoffs, because Zero Trust is not free.

By the end, you will have a concrete mental model and working code you can adapt to your own services.


The Problem: Why the Perimeter Broke

The castle-and-moat security model rests on one assumption: that the boundary between "inside" and "outside" is meaningful and enforceable. Every architectural decision since 2010 has systematically destroyed that assumption.

Cloud-native infrastructure erased the inside. When your application runs across multiple cloud providers and regions, there is no single network boundary. A Kubernetes pod in us-east-1 talking to a managed database in eu-west-1 is not "inside" anything. Traffic travels over paths controlled by third parties. The old mental model of "trust internal IPs" becomes actively dangerous because internal IP ranges overlap between VPCs, between cloud providers, and between tenants on shared infrastructure.

Remote work and contractor access expanded the edge to everywhere. Your developers are not in an office behind a managed switch. They are connecting from personal routers with default passwords, from hotel networks, from devices that may or may not have EDR installed. VPNs were designed to extend the perimeter to remote employees, but they do so by essentially putting those employees "inside" the castle — with all the trust that implies. A compromised developer laptop on a VPN has full lateral movement access to anything the VPN permits.

Lateral movement is the real threat. Major breaches are rarely about a single endpoint getting owned. They are about attackers using that initial foothold to move laterally through a network that trusted internal traffic implicitly. The 2020 SolarWinds attack, the 2021 Colonial Pipeline ransomware, and dozens of high-profile cloud breaches all followed the same pattern: initial access, lateral movement, persistence, exfiltration. Perimeter security stops initial access (sometimes). It does almost nothing about lateral movement once an attacker is inside.

Third-party dependencies and SaaS integrations punched holes in the moat. Modern applications integrate with dozens of external services: payment processors, identity providers, analytics platforms, communication tools. Each integration is a potential entry point. Each API key stored in a .env file is a credential that, if leaked, grants access from outside the perimeter entirely.

The implicit trust model creates hidden attack surface. When service A trusts service B simply because B is on the same internal network, an attacker who compromises any internal service can impersonate any other. There is no cryptographic proof of identity — just network topology, which is increasingly meaningless.

Zero Trust addresses all of these by shifting the security model from "where are you" to "who are you, what do you want, and can I verify both cryptographically."

The three core principles:

  1. Never trust, always verify — Every request must present verifiable credentials. Network location is not a credential.
  2. Least privilege — Every identity (user, service, device) gets only the permissions it needs for the specific action, at the specific time, with the specific scope.
  3. Assume breach — Design systems as if an attacker is already inside. Minimize blast radius, segment access, log everything, and detect anomalies.
Architecture diagram: Zero Trust vs perimeter model — identity verification at each service boundary

How It Works: The Technical Building Blocks

Zero Trust is not a product you buy. It is a set of technical patterns you implement across your infrastructure and code. Let us walk through the key mechanisms.

Workload Identity with SPIFFE and SPIRE

The first problem to solve is: how does a service prove who it is? Usernames and passwords are unsuitable for machine-to-machine communication. Static API keys are better but require manual rotation and out-of-band distribution. The modern answer is workload identity — cryptographic attestation of what a piece of software is, based on verifiable properties of its runtime environment.

SPIFFE (Secure Production Identity Framework For Everyone) is the open standard for workload identity. A SPIFFE identity is a URI in the form spiffe://trust-domain/path — for example, spiffe://prod.example.com/payments-service. This URI is embedded in an X.509 certificate called an SVID (SPIFFE Verifiable Identity Document).

SPIRE is the reference implementation of SPIFFE. A SPIRE server manages the trust domain and issues SVIDs. SPIRE agents run on each node, attest workloads using platform-specific mechanisms (Kubernetes service account tokens, AWS instance identity documents, TPM attestation), and deliver short-lived SVIDs to workloads via a Unix domain socket.

The key properties of this model:

  • SVIDs are short-lived (typically 1 hour or less), so a compromised certificate has a small window of validity.
  • Attestation is automatic — a new pod gets an identity without a human issuing a certificate manually.
  • The trust domain is cryptographically rooted, so certificates cannot be forged.

Once your services have SPIFFE identities, you can use those identities in mTLS connections, OIDC token exchange, and policy evaluation.

Mutual TLS (mTLS)

Standard TLS authenticates the server to the client. The client verifies that the server's certificate was issued by a trusted CA and matches the domain it is connecting to. The server knows nothing verifiable about the client.

Mutual TLS adds client authentication. Both sides present certificates. Both sides verify the other's certificate against a trusted CA. The result is a cryptographically authenticated channel where both parties know exactly who they are talking to.

For service-to-service communication in a Zero Trust model, mTLS is the baseline. When the payments service calls the inventory service, the inventory service does not just trust the call because it came from an internal IP. It verifies the caller's SPIFFE SVID, confirms it maps to an identity it is permitted to accept requests from, and only then processes the request.

In a service mesh (Istio, Linkerd, Consul Connect), mTLS happens transparently in the sidecar proxy. Application code does not need to handle certificate management directly. But understanding what is happening underneath is essential for writing correct authorization policies and debugging failures.

JWT Token Validation

For user-facing APIs, the equivalent of mTLS is rigorous JWT validation. A JSON Web Token carries claims about the authenticated user, signed by an identity provider. The API must verify the signature, validate the claims, and enforce authorization before processing any request.

JWT validation sounds simple but has many pitfalls:

  • Algorithm confusion attacks: An attacker manipulates the alg header to none or switches from RS256 to HS256, using the public key as the HMAC secret. Libraries that respect the alg header field from the token rather than requiring a specific algorithm are vulnerable.
  • Audience and issuer validation: A JWT from your staging environment signed by your staging IdP should never be accepted by your production API. Always validate aud and iss claims explicitly.
  • Expiry validation: Always check exp. Do not accept tokens without an expiry claim.
  • Key rotation: Your validation logic must be able to fetch updated JWKS without restarting the service.

OAuth 2.0 and Token Exchange

For service-to-service calls that cross trust boundaries — calling an external API, or calling an internal API on behalf of a user — OAuth 2.0 token exchange (RFC 8693) allows a service to trade one token for a scoped token specific to the downstream call. This maintains the least-privilege principle: the downstream service receives a token scoped only to what it needs, not the original user's full credential.

Policy-Based Authorization

Authentication proves identity. Authorization determines what that identity is permitted to do. In a Zero Trust model, authorization should be explicit, centralized (or consistently distributed), and evaluated per request — not baked into application code as ad-hoc if/else checks.

Open Policy Agent (OPA) is the most widely adopted policy engine for this. You write authorization policy in Rego, a purpose-built policy language. At request time, your service sends a structured input document to OPA (or the embedded library) and receives a decision. OPA decouples policy from application code, allows policy to be versioned and tested independently, and can be audited.

Cedar (from AWS) is a newer alternative with a focus on formal verification and performance. It uses a different policy language with a strong type system and is designed for high-throughput authorization decisions.

flowchart TD A[Incoming Request] --> B{Has valid token?} B -- No --> C[Return 401 Unauthorized] B -- Yes --> D{Token signature valid?} D -- No --> C D -- Yes --> E{Token not expired?} E -- No --> F[Return 401 Token Expired] E -- Yes --> G{Issuer and audience match?} G -- No --> C G -- Yes --> H[Extract identity claims] H --> I{OPA policy check} I -- Deny --> J[Return 403 Forbidden] I -- Allow --> K[Process request] K --> L[Return 200 with response] style C fill:#ff4444,color:#fff style F fill:#ff4444,color:#fff style J fill:#ff8800,color:#fff style L fill:#22aa44,color:#fff

Implementation Guide

Let us write the code. All examples use Python with FastAPI, but the patterns apply to any stack.

1. JWT Validation Middleware

This middleware validates every incoming request, extracts verified claims, and attaches them to the request context. Application route handlers can then access the verified identity without repeating validation logic.

"""
jwt_middleware.py — Zero Trust JWT validation for FastAPI services.

Validates RS256-signed JWTs from an OIDC-compatible identity provider.
Fetches public keys from the JWKS endpoint and caches them with rotation support.
"""

import time
import httpx
import jwt
from jwt import PyJWKClient, InvalidTokenError, ExpiredSignatureError
from fastapi import Request, HTTPException, status
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
from functools import lru_cache
from typing import Optional


# Configuration — load from environment in production
JWKS_URI = "https://auth.example.com/.well-known/jwks.json"
EXPECTED_ISSUER = "https://auth.example.com/"
EXPECTED_AUDIENCE = "api://payments-service"

# Paths that bypass JWT validation (health checks, metrics endpoints)
PUBLIC_PATHS = {"/health", "/metrics", "/ready"}


class JWTValidationMiddleware(BaseHTTPMiddleware):
    """
    Middleware that validates JWT Bearer tokens on every protected request.

    Uses PyJWKClient for automatic key rotation: it fetches the JWKS from
    the identity provider and caches signing keys, re-fetching when an
    unknown key ID (kid) is encountered.
    """

    def __init__(self, app, jwks_uri: str = JWKS_URI):
        super().__init__(app)
        # PyJWKClient handles JWKS fetching, caching, and rotation automatically.
        # lifespan_seconds controls how long a cached key is trusted before
        # re-fetching — set to 3600 (1 hour) to handle routine key rotation.
        self.jwks_client = PyJWKClient(
            jwks_uri,
            lifespan_seconds=3600,
            headers={"User-Agent": "payments-service/1.0"},
        )

    async def dispatch(self, request: Request, call_next):
        # Skip validation for public paths
        if request.url.path in PUBLIC_PATHS:
            return await call_next(request)

        # Extract Bearer token from Authorization header
        token = self._extract_bearer_token(request)
        if token is None:
            return JSONResponse(
                status_code=status.HTTP_401_UNAUTHORIZED,
                content={"error": "missing_token", "detail": "Authorization header required"},
                headers={"WWW-Authenticate": "Bearer"},
            )

        # Validate and decode the token
        claims = self._validate_token(token)
        if claims is None:
            return JSONResponse(
                status_code=status.HTTP_401_UNAUTHORIZED,
                content={"error": "invalid_token", "detail": "Token validation failed"},
                headers={"WWW-Authenticate": "Bearer error=\"invalid_token\""},
            )

        # Attach verified claims to request state for use by route handlers
        request.state.identity = claims
        request.state.subject = claims.get("sub")
        request.state.scopes = set(claims.get("scope", "").split())

        return await call_next(request)

    def _extract_bearer_token(self, request: Request) -> Optional[str]:
        """Extract the raw JWT from the Authorization: Bearer <token> header."""
        auth_header = request.headers.get("Authorization", "")
        if not auth_header.startswith("Bearer "):
            return None
        token = auth_header[len("Bearer "):]
        return token if token else None

    def _validate_token(self, token: str) -> Optional[dict]:
        """
        Full JWT validation:
        1. Fetch the correct signing key from JWKS (by kid in token header)
        2. Verify RS256 signature — never accept 'none' or HS256
        3. Validate expiry (exp), issuer (iss), and audience (aud)
        """
        try:
            # Get the signing key matching the token's kid header.
            # This raises PyJWKClientError if the key is not found,
            # which triggers a JWKS re-fetch automatically.
            signing_key = self.jwks_client.get_signing_key_from_jwt(token)

            claims = jwt.decode(
                token,
                signing_key.key,
                algorithms=["RS256"],  # Explicitly whitelist — never accept 'none'
                audience=EXPECTED_AUDIENCE,
                issuer=EXPECTED_ISSUER,
                options={
                    "require": ["exp", "iat", "sub", "iss", "aud"],
                    "verify_exp": True,
                    "verify_iat": True,
                },
            )
            return claims

        except ExpiredSignatureError:
            # Log separately — useful for debugging clock skew issues
            return None
        except InvalidTokenError:
            return None
        except Exception:
            # Catch-all for unexpected errors (network issues fetching JWKS, etc.)
            return None


# Example route handler using verified identity from middleware
from fastapi import FastAPI, Depends

app = FastAPI()
app.add_middleware(JWTValidationMiddleware)


def require_scope(required_scope: str):
    """Dependency that checks for a specific OAuth scope in the verified token."""
    def check_scope(request: Request):
        if required_scope not in request.state.scopes:
            raise HTTPException(
                status_code=status.HTTP_403_FORBIDDEN,
                detail=f"Required scope '{required_scope}' not present",
            )
        return request.state.identity
    return check_scope


@app.get("/payments/{payment_id}")
async def get_payment(
    payment_id: str,
    identity=Depends(require_scope("payments:read")),
):
    """
    This route handler only runs if:
    - A valid, non-expired JWT was presented
    - The token has the 'payments:read' scope
    The identity dict contains verified claims (sub, email, roles, etc.)
    """
    return {
        "payment_id": payment_id,
        "requested_by": identity["sub"],
    }

2. mTLS Client Certificate Verification

When services communicate with each other, mTLS provides cryptographic authentication on both sides. In Python, this is typically handled at the server level by configuring the TLS termination to require and verify client certificates. Here is how to configure it in a FastAPI service running behind uvicorn, and how to add application-level verification of the SPIFFE identity in the certificate.

"""
mtls_server.py — Configure mTLS with SPIFFE identity verification.

This module shows two layers of mTLS enforcement:
1. TLS-level: uvicorn requires a client certificate signed by our CA.
2. Application-level: We extract and verify the SPIFFE URI SAN from the cert.

In a service mesh (Istio/Linkerd), layer 1 is handled by the sidecar proxy.
Layer 2 should still be done in application code for defense in depth.
"""

import ssl
import uvicorn
from fastapi import Request, HTTPException, status
from cryptography import x509
from cryptography.hazmat.backends import default_backend
from cryptography.x509.oid import ExtensionOID
from typing import Optional
import re


# Allowed SPIFFE identities that may call this service.
# In production, load from a policy store or environment config.
ALLOWED_CALLER_IDENTITIES = {
    "spiffe://prod.example.com/orders-service",
    "spiffe://prod.example.com/api-gateway",
}

TRUST_DOMAIN = "prod.example.com"


def extract_spiffe_id_from_cert(cert_der: bytes) -> Optional[str]:
    """
    Parse the DER-encoded client certificate and extract the SPIFFE ID
    from the Subject Alternative Name (SAN) URI extension.

    SPIFFE SVIDs embed the workload identity as a URI SAN in the form:
      spiffe://trust-domain/workload-path

    Returns the SPIFFE URI string, or None if not present.
    """
    try:
        cert = x509.load_der_x509_certificate(cert_der, default_backend())
        san_extension = cert.extensions.get_extension_for_oid(
            ExtensionOID.SUBJECT_ALTERNATIVE_NAME
        )
        san = san_extension.value

        # Extract URI-type SANs and find the SPIFFE one
        for uri in san.get_values_for_type(x509.UniformResourceIdentifier):
            if uri.startswith("spiffe://"):
                return uri

        return None
    except Exception:
        return None


def verify_spiffe_identity(spiffe_id: Optional[str]) -> bool:
    """
    Verify that the presented SPIFFE ID:
    1. Belongs to our trust domain (not a foreign SPIRE instance)
    2. Is in the allowed callers list for this service

    This is the authorization step — even a valid mTLS connection from
    a legitimate service should be rejected if it is not authorized to
    call this specific service.
    """
    if spiffe_id is None:
        return False

    # Validate trust domain to prevent cross-domain identity confusion
    expected_prefix = f"spiffe://{TRUST_DOMAIN}/"
    if not spiffe_id.startswith(expected_prefix):
        return False

    return spiffe_id in ALLOWED_CALLER_IDENTITIES


# FastAPI middleware to enforce SPIFFE identity at the application layer
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse


class SPIFFEIdentityMiddleware(BaseHTTPMiddleware):
    """
    Extracts and verifies the SPIFFE identity from the mTLS client certificate.

    NOTE: This middleware requires that uvicorn/the TLS terminator is configured
    to pass the client certificate to the application. When running behind a
    reverse proxy or service mesh, the proxy typically passes the cert via the
    X-Forwarded-Client-Cert header (XFCC) — adjust extraction accordingly.
    """

    async def dispatch(self, request: Request, call_next):
        # In direct uvicorn mTLS, the client cert is accessible via
        # request.scope["transport"].get_extra_info("peercert").
        # For XFCC header (Envoy/Istio), parse from the header value.

        spiffe_id = self._get_spiffe_id_from_request(request)

        if not verify_spiffe_identity(spiffe_id):
            return JSONResponse(
                status_code=status.HTTP_403_FORBIDDEN,
                content={
                    "error": "unauthorized_caller",
                    "detail": f"SPIFFE identity '{spiffe_id}' is not authorized",
                },
            )

        # Attach verified workload identity to request state
        request.state.caller_spiffe_id = spiffe_id
        return await call_next(request)

    def _get_spiffe_id_from_request(self, request: Request) -> Optional[str]:
        """
        Extract SPIFFE ID from XFCC header (Istio/Envoy format).

        The X-Forwarded-Client-Cert header in Envoy contains the client cert
        fields in a structured format. We extract the URI SAN from it.

        Example XFCC value:
          Hash=abc123;URI=spiffe://prod.example.com/orders-service;...
        """
        xfcc = request.headers.get("X-Forwarded-Client-Cert", "")
        if not xfcc:
            return None

        # Parse URI field from XFCC header
        uri_match = re.search(r'URI=([^;,]+)', xfcc)
        if uri_match:
            return uri_match.group(1)

        return None


def create_mtls_ssl_context(
    cert_path: str,
    key_path: str,
    ca_cert_path: str,
) -> ssl.SSLContext:
    """
    Create an SSL context for uvicorn that:
    - Presents our service certificate to clients
    - Requires clients to present a certificate (CERT_REQUIRED)
    - Verifies client certificates against our CA bundle (SPIRE CA)
    """
    ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
    ctx.load_cert_chain(certfile=cert_path, keyfile=key_path)
    ctx.load_verify_locations(cafile=ca_cert_path)
    ctx.verify_mode = ssl.CERT_REQUIRED  # Reject connections without a client cert
    ctx.minimum_version = ssl.TLSVersion.TLSv1_3  # Enforce TLS 1.3 minimum
    return ctx


# To run with mTLS:
# ssl_ctx = create_mtls_ssl_context(
#     cert_path="/run/spiffe/svid/cert.pem",
#     key_path="/run/spiffe/svid/key.pem",
#     ca_cert_path="/run/spiffe/bundle/bundle.crt",
# )
# uvicorn.run(app, host="0.0.0.0", port=8443, ssl=ssl_ctx)

3. Policy-Based Authorization with OPA

Authentication and identity verification tell you who is making the request. Authorization policy tells you whether they are allowed to do what they are asking. Rather than embedding authorization logic as if/else conditions in route handlers, use a policy engine that can be managed, versioned, and tested independently.

"""
opa_authz.py — Policy-based authorization using Open Policy Agent.

Sends a structured authorization request to OPA and uses the decision
to allow or deny the incoming API request. Policy is defined in Rego
files managed separately from application code.
"""

import httpx
from fastapi import Request, HTTPException, status, Depends
from typing import Any, Optional
import logging

logger = logging.getLogger(__name__)

# OPA server endpoint — in production, OPA runs as a sidecar or local agent
OPA_URL = "http://localhost:8181/v1/data/payments/authz/allow"
OPA_TIMEOUT_SECONDS = 0.1  # Keep authorization decisions fast — 100ms max


class OPAAuthorizationError(Exception):
    pass


async def check_opa_policy(
    input_document: dict,
    opa_url: str = OPA_URL,
) -> bool:
    """
    Send an authorization request to OPA and return the boolean decision.

    OPA evaluates the request against the loaded Rego policy and returns
    a JSON response. We check the 'result' field for the allow decision.

    The input_document should contain everything the policy needs to make
    a decision: identity claims, the action being performed, and the resource.
    """
    try:
        async with httpx.AsyncClient(timeout=OPA_TIMEOUT_SECONDS) as client:
            response = await client.post(
                opa_url,
                json={"input": input_document},
            )
            response.raise_for_status()
            result = response.json()
            # OPA returns {"result": true} or {"result": false}
            return bool(result.get("result", False))

    except httpx.TimeoutException:
        # On OPA timeout, fail closed — deny the request
        logger.error("OPA authorization timeout — denying request for safety")
        return False
    except httpx.HTTPError as e:
        logger.error(f"OPA HTTP error: {e} — denying request")
        return False


def build_authz_input(
    request: Request,
    resource_id: Optional[str] = None,
) -> dict:
    """
    Construct the input document sent to OPA for evaluation.

    The structure of this document must match what the Rego policy expects.
    Include everything the policy might need: identity, action, resource, context.
    """
    identity = getattr(request.state, "identity", {})
    caller_service = getattr(request.state, "caller_spiffe_id", None)

    return {
        "subject": {
            "user_id": identity.get("sub"),
            "roles": identity.get("roles", []),
            "scopes": list(getattr(request.state, "scopes", set())),
            "service": caller_service,
        },
        "action": {
            "method": request.method,
            "path": request.url.path,
        },
        "resource": {
            "type": "payment",
            "id": resource_id,
        },
        "context": {
            "ip": request.client.host if request.client else None,
            "user_agent": request.headers.get("User-Agent"),
        },
    }


def require_policy_allow(resource_id_param: Optional[str] = None):
    """
    FastAPI dependency factory that enforces OPA policy for a route.

    Usage:
        @app.delete("/payments/{payment_id}")
        async def delete_payment(
            payment_id: str,
            _=Depends(require_policy_allow("payment_id")),
        ):
            ...
    """
    async def enforce(request: Request):
        input_doc = build_authz_input(
            request,
            resource_id=request.path_params.get(resource_id_param) if resource_id_param else None,
        )

        allowed = await check_opa_policy(input_doc)

        if not allowed:
            logger.warning(
                f"OPA denied {request.method} {request.url.path} "
                f"for subject {input_doc['subject']}"
            )
            raise HTTPException(
                status_code=status.HTTP_403_FORBIDDEN,
                detail="Policy evaluation denied this request",
            )

    return enforce


# Example Rego policy (payments/authz.rego — managed in a separate policy repo):
#
# package payments.authz
#
# default allow = false
#
# # Admins can do anything
# allow {
#     "admin" in input.subject.roles
# }
#
# # Service-to-service: orders-service can read payments
# allow {
#     input.subject.service == "spiffe://prod.example.com/orders-service"
#     input.action.method == "GET"
# }
#
# # Users can read their own payments if they have the right scope
# allow {
#     input.action.method == "GET"
#     "payments:read" in input.subject.scopes
# }
#
# # Users cannot delete payments — even with admin role, require 2FA context
# allow {
#     input.action.method == "DELETE"
#     "admin" in input.subject.roles
#     input.context.mfa_verified == true
# }


# Route using full Zero Trust stack: JWT validation + mTLS + OPA
from fastapi import FastAPI
app = FastAPI()


@app.delete(
    "/payments/{payment_id}",
    dependencies=[Depends(require_policy_allow("payment_id"))],
)
async def delete_payment(payment_id: str, request: Request):
    """
    This route is protected by three layers:
    1. JWTValidationMiddleware — validates the Bearer token
    2. SPIFFEIdentityMiddleware — verifies caller's mTLS certificate
    3. OPA policy — evaluates fine-grained authorization rules
    """
    return {"deleted": payment_id, "by": request.state.identity.get("sub")}
sequenceDiagram participant C as Client Service participant GW as API Gateway participant SV as Payments Service participant OPA as OPA Sidecar participant DB as Database C->>GW: POST /payments (JWT + mTLS cert) GW->>GW: Verify JWT signature & claims GW->>GW: Validate mTLS client cert (SPIFFE) GW->>SV: Forward request (XFCC header set) SV->>SV: Extract SPIFFE ID from XFCC SV->>SV: Verify SPIFFE ID in allowlist SV->>OPA: POST /v1/data/payments/authz/allow OPA->>OPA: Evaluate Rego policy OPA-->>SV: {"result": true} SV->>DB: Execute query with verified identity DB-->>SV: Result SV-->>GW: 200 OK GW-->>C: 200 OK Note over C,DB: Every hop is authenticated and authorized independently

Comparison and Tradeoffs

Traditional VPN vs Zero Trust

The VPN model was designed to extend a trusted network to remote users. It solves the "employee is not in the office" problem by putting them back on the internal network. Zero Trust solves a fundamentally different problem: it treats the network itself as untrusted, regardless of where you are connecting from.

Dimension Traditional VPN / Perimeter Zero Trust
Trust model Trust by network location Trust by verified identity only
Authentication Single point at VPN gateway Per-request, per-service
Lateral movement Unrestricted inside the perimeter Limited by per-service authorization
Credential scope VPN credential grants broad access Tokens/certs scoped to specific services
Breach blast radius High — attacker has full internal access Low — compromise limited to one workload's permissions
Auditability Coarse-grained (who was on VPN, when) Fine-grained (who called what, with what identity, what was decided)
Developer experience Connect once, access everything Additional headers/tokens per service (mitigated by service mesh)
Operational complexity Simple once set up Higher — SPIRE, OPA, JWKS rotation, mTLS all require ops investment
Performance VPN latency at edge only Latency at every service boundary (typically 1-5ms per hop)

Security Model Comparison

Pattern Threat it addresses Limitation
mTLS Service impersonation, man-in-the-middle Does not control what an authenticated service is allowed to do
JWT validation Forged user identity Does not authenticate the calling service
SPIFFE/SPIRE Workload identity spoofing, static credentials Requires SPIRE infrastructure investment
OPA policy Over-broad authorization, inconsistent access control Policy correctness depends on Rego code quality and test coverage
Service mesh (Istio) mTLS complexity, certificate management Sidecar overhead (CPU and memory per pod)

When to Use a Service Mesh vs Application-Level mTLS

A service mesh (Istio, Linkerd, Consul Connect) implements mTLS and workload identity transparently at the infrastructure layer. Application code does not change. This is ideal for organizations with many services and dedicated platform engineering capacity.

Application-level mTLS and SPIFFE integration is appropriate when:
- You have a small number of services and cannot absorb the operational complexity of a full service mesh.
- You need fine-grained control that goes beyond what mesh-level policy can express.
- You are running on infrastructure where sidecar injection is impractical (e.g., Lambda, managed container services without sidecar support).

A service mesh does not eliminate the need for application-level authorization. Mesh-level policy is coarse-grained (can service A talk to service B at all). OPA or Cedar adds fine-grained authorization (can service A call the DELETE /payments/{id} endpoint on service B for payment ID 12345, given the current user context).

Comparison visual: service mesh mTLS vs application-level mTLS and OPA authorization layers
graph LR subgraph "Traditional Perimeter" FW[Firewall] --> |"Trusted internal traffic"| S1[Service A] FW --> S2[Service B] FW --> S3[Service C] S1 --> |"No auth needed"| S2 S2 --> |"No auth needed"| S3 end subgraph "Zero Trust" GW2[API Gateway] --> |"JWT validated"| SA[Service A
SPIFFE ID] SA --> |"mTLS + OPA check"| SB[Service B
SPIFFE ID] SB --> |"mTLS + OPA check"| SC[Service C
SPIFFE ID] SPIRE[SPIRE Server] -.->|"Issues SVID"| SA SPIRE -.->|"Issues SVID"| SB SPIRE -.->|"Issues SVID"| SC OPA2[OPA Policy] -.->|"Auth decisions"| SA OPA2 -.->|"Auth decisions"| SB OPA2 -.->|"Auth decisions"| SC end style FW fill:#cc3333,color:#fff style GW2 fill:#2266cc,color:#fff style SPIRE fill:#226622,color:#fff style OPA2 fill:#226622,color:#fff

Production Considerations

Certificate Rotation

Short-lived SVIDs are a feature, not a limitation, but they require your services to handle rotation gracefully. SPIRE agents automatically renew SVIDs before expiry and deliver the new credential via the Workload API. Your services need to:

  • Watch the Workload API socket for updates rather than reading the certificate once at startup. The SPIFFE Workload API provides a streaming gRPC interface that pushes updates automatically.
  • Not cache TLS connections indefinitely. Connection pools should respect certificate expiry. A connection established with an old certificate should be torn down and re-established after rotation.
  • Test rotation in staging. Set a very short SVID TTL (5 minutes) in staging and run load tests during rotation events to catch issues before production.

Key Management

SPIRE server is a critical piece of infrastructure. Its signing keys must be protected. In production:

  • Run SPIRE server with an external key manager — AWS KMS or HashiCorp Vault — rather than storing signing keys on disk.
  • Deploy SPIRE server in an HA configuration with an external database backend (PostgreSQL).
  • Treat the SPIRE server's availability as equivalent to your authentication infrastructure — if SPIRE is down and SVIDs expire, services lose the ability to authenticate to each other.

For JWT signing keys managed by your IdP (Auth0, Keycloak, Okta), ensure your JWKS fetching logic handles key rotation without service restarts. The PyJWKClient implementation shown earlier does this automatically by re-fetching when an unknown kid is encountered.

Performance Overhead

Zero Trust adds latency at every service boundary. Understanding the budget:

  • JWKS fetch and JWT validation: Negligible after the first request — signing keys are cached in memory. Budget 0.1-0.5ms per token validation with a warm cache.
  • mTLS handshake: TLS 1.3 with session resumption (tickets or session IDs) reduces handshake overhead to one round trip for resumed sessions. For new connections, budget 1-2ms for the handshake.
  • OPA policy evaluation: 1-5ms for most policies when OPA runs as a sidecar (local network call). With the embedded Go library (github.com/open-policy-agent/opa/rego), evaluation drops to under 1ms. The Python opa-python-client library adds network overhead — prefer the sidecar model.
  • Istio sidecar (Envoy): Adds 1-3ms per hop on average, 5-10ms at P99 under load, with 50-100MB memory overhead per pod.

For most API services, these overheads are negligible compared to database query times and business logic processing. The exception is high-frequency internal service calls (tens of thousands per second per service) where connection pool management and session resumption become critical.

Monitoring and Anomaly Detection

Zero Trust generates rich telemetry. Use it:

  • Log every authorization decision from OPA — allowed and denied. Denied requests are signals of misconfiguration, attempted lateral movement, or bugs.
  • Alert on SPIFFE attestation failures — a workload that cannot get a certificate is likely a deployment issue, but a sustained pattern of failures from unexpected nodes can indicate an attack.
  • Trace request identity across service boundaries with distributed tracing. Include the SPIFFE ID and JWT subject in trace attributes so you can reconstruct the full identity chain for any request.
  • Set SLOs on certificate renewal latency. If SVIDs are not renewed with sufficient buffer before expiry, services will start rejecting each other's connections.

Conclusion

The network perimeter as a security boundary is gone. Distributed systems, remote work, and cloud-native infrastructure have dismantled it, and no amount of VPN tunnel engineering will reassemble it. The question is not whether to move to a Zero Trust model — it is how to get there incrementally without breaking production.

The path is practical and well-defined. Start with JWT validation at your API gateway and standardize on it across all services. Move to SPIFFE/SPIRE for workload identity as you scale your service mesh — or adopt Istio or Linkerd to get mTLS for free at the infrastructure layer. Add OPA for authorization logic that is too complex or too important to live in application if/else blocks. Each step independently improves your security posture.

The code in this post gives you working starting points: a FastAPI middleware that handles JWT validation correctly (including algorithm whitelist enforcement, JWKS rotation, and claim validation), an mTLS setup with SPIFFE identity extraction from both direct TLS and Envoy's XFCC header, and an OPA integration pattern that keeps authorization decisions fast and fails closed on timeout.

Zero Trust is not a product you install on Tuesday and call done. It is an architectural discipline — a continuous process of making every assumption about identity and access explicit, verifiable, and auditable. The developers who understand it will build systems that are resilient to the breach scenarios that are inevitable in any large distributed environment. The ones who do not will continue to rely on a moat that has already been drained.

The perimeter is gone. Identity is what you have left. Build from there.


Next in the API Security series: Rate Limiting AI Agents: Protecting APIs from Intelligent Abuse.

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-07 · 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...