All articles
Authentication Security Backend Node.js

JWT Authentication Authentication Strategies for Modern Web Applications

Palakorn Voramongkol
April 10, 2025 14 min read

“A comprehensive implementation guide to JWT authentication — covering token structure, signing algorithms, access/refresh token patterns, middleware implementation, token revocation strategies, and security best practices.”

Deep Dive: JWT Authentication

What is JWT?

JSON Web Token (JWT, pronounced “jot”) is an open standard (RFC 7519) for securely transmitting information between parties as a compact, URL-safe token. Unlike session-based auth where the server stores user state, JWT is stateless — the token itself contains all the information needed to authenticate a user, cryptographically signed to prevent tampering.

A JWT is a string of three Base64URL-encoded parts separated by dots: header.payload.signature. The header declares the signing algorithm, the payload carries claims (user data), and the signature ensures integrity. Any server with the signing key can verify the token independently — no database lookup, no shared session store.

JWT emerged from the OAuth 2.0 and OpenID Connect ecosystem and has become the de facto standard for API authentication, microservice communication, and mobile app backends.

Core Principles

  • Stateless: The token is self-contained. Servers verify it using cryptography, not database lookups.
  • Compact: Small enough to send in HTTP headers, URL parameters, or POST bodies.
  • Signed, not encrypted: Anyone can read the payload (it’s Base64-encoded, not encrypted). The signature only guarantees it hasn’t been tampered with. Never put secrets in the payload.
  • Expiry-based lifecycle: Tokens are valid until their exp claim. They cannot be revoked without additional infrastructure (blacklists).

Authentication Flow

sequenceDiagram
    participant B as Browser / Client
    participant S as Server
    participant DB as Database

    B->>S: POST /login {email, password}
    S->>DB: Validate credentials
    DB-->>S: User {id, email, role}
    S->>S: Sign JWT (access token, 15min)
    S->>S: Sign JWT (refresh token, 7d)
    S->>DB: Store refresh token hash
    S-->>B: {accessToken, refreshToken, expiresIn: 900}
    Note over B: Store tokens (memory / httpOnly cookie)

    B->>S: GET /api/data (Authorization: Bearer <accessToken>)
    S->>S: Verify JWT signature + expiry
    S-->>B: 200 OK — Protected data

    Note over B: Access token expired after 15min
    B->>S: POST /refresh {refreshToken}
    S->>DB: Verify refresh token hash
    DB-->>S: Valid
    S->>S: Sign new access token
    S-->>B: {accessToken, expiresIn: 900}

Now let’s break down each component in detail.

Part 1: Understanding JWT Structure

The first part is the header—a JSON object describing the token type and signing algorithm:

{
  "alg": "HS256",
  "typ": "JWT"
}

Common algorithms:

  • HS256: HMAC with SHA-256 (symmetric—same secret signs and verifies)
  • RS256: RSA with SHA-256 (asymmetric—private key signs, public key verifies)
  • ES256: ECDSA with SHA-256 (asymmetric—shorter keys, faster than RSA)

Payload

The second part contains the claims—the actual data:

{
  "sub": "user:12345",
  "email": "alice@example.com",
  "role": "admin",
  "iat": 1516239022,
  "exp": 1516242622
}

Reserved claims:

  • iss (issuer): Who created the token
  • sub (subject): Who the token is about
  • aud (audience): Who should accept the token
  • exp (expiration time): Unix timestamp—token invalid after this time
  • iat (issued at): Unix timestamp—when the token was created
  • nbf (not before): Token invalid before this time
  • jti (JWT ID): Unique identifier for this token

You can add custom claims, but avoid storing sensitive PII like passwords, SSNs, or financial data.

Signature

The third part is the digital signature, which proves the token hasn’t been tampered with:

HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secret
)

The server uses the same secret (or public key) to verify this signature. If someone modifies the payload without the secret, the signature won’t match.

Part 2: Signing Algorithms Deep Dive

Choosing the right algorithm is critical for security and performance.

HS256 (HMAC-SHA256) — Symmetric

Both client and server share the same secret key.

Pros:

  • Simple to understand and implement
  • Fast
  • Good for monolithic applications

Cons:

  • You must share the secret with every service that verifies tokens
  • Secret exposure is catastrophic—attacker can forge any token
  • Not suitable for distributed systems

Use case: Microservices with a single auth server and tight backend communication.

RS256 (RSA-SHA256) — Asymmetric

Server signs with a private key; services verify with the public key.

Pros:

  • Public key can be safely shared and published
  • Multiple services can verify tokens without sharing secrets
  • Industry standard (used by OAuth2, OpenID Connect)

Cons:

  • Slower signature generation and verification
  • Larger key sizes (2048-4096 bits)

Use case: Distributed systems, public APIs, OAuth2 providers.

ES256 (ECDSA-SHA256) — Asymmetric

Like RSA but using elliptic curve cryptography—smaller keys, faster operations.

Pros:

  • Faster than RSA
  • Smaller keys (256 bits vs 2048 bits)
  • Lower bandwidth

Cons:

  • Less widely supported
  • Slightly harder to understand

Use case: High-performance systems, mobile apps, edge computing.

Part 3: Full Implementation — Generating Tokens

Here’s a production-ready implementation using Express and TypeScript:

import jwt from 'jsonwebtoken';
import crypto from 'crypto';

// ============================================================================
// Token Configuration
// ============================================================================

interface TokenPayload {
  sub: string; // user ID
  email: string;
  role: 'user' | 'admin';
  type: 'access' | 'refresh'; // Token type
  iat?: number;
  exp?: number;
  jti?: string; // JWT ID for revocation tracking
}

const TOKEN_CONFIG = {
  access: {
    expiresIn: '15m', // Short-lived
    algorithm: 'RS256' as const,
  },
  refresh: {
    expiresIn: '7d', // Long-lived
    algorithm: 'RS256' as const,
  },
};

// ============================================================================
// Generate RSA Key Pair (run once in production, store in env)
// ============================================================================

function generateKeyPair() {
  const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
    modulusLength: 2048,
    publicKeyEncoding: {
      type: 'spki',
      format: 'pem',
    },
    privateKeyEncoding: {
      type: 'pkcs8',
      format: 'pem',
    },
  });

  return { publicKey, privateKey };
}

// In production, load from environment:
const PRIVATE_KEY = process.env.JWT_PRIVATE_KEY || generateKeyPair().privateKey;
const PUBLIC_KEY = process.env.JWT_PUBLIC_KEY || generateKeyPair().publicKey;

// ============================================================================
// Token Generation
// ============================================================================

/**
 * Generate an access token (short-lived)
 */
function generateAccessToken(userId: string, email: string, role: string): string {
  const payload: TokenPayload = {
    sub: userId,
    email,
    role: role as 'user' | 'admin',
    type: 'access',
    jti: crypto.randomUUID(), // Unique token ID for revocation
  };

  return jwt.sign(payload, PRIVATE_KEY, {
    algorithm: 'RS256',
    expiresIn: '15m',
    issuer: 'api.example.com',
    audience: 'api.example.com',
  });
}

/**
 * Generate a refresh token (long-lived)
 */
function generateRefreshToken(userId: string): string {
  const payload: TokenPayload = {
    sub: userId,
    email: '', // Not needed in refresh token
    role: 'user',
    type: 'refresh',
    jti: crypto.randomUUID(),
  };

  return jwt.sign(payload, PRIVATE_KEY, {
    algorithm: 'RS256',
    expiresIn: '7d',
    issuer: 'api.example.com',
    audience: 'api.example.com',
  });
}

/**
 * Generate both access and refresh tokens
 */
function generateTokenPair(userId: string, email: string, role: string) {
  return {
    accessToken: generateAccessToken(userId, email, role),
    refreshToken: generateRefreshToken(userId),
  };
}

// ============================================================================
// Token Verification
// ============================================================================

/**
 * Verify a token and return its payload
 */
function verifyToken(token: string): TokenPayload {
  try {
    const decoded = jwt.verify(token, PUBLIC_KEY, {
      algorithms: ['RS256'],
      issuer: 'api.example.com',
      audience: 'api.example.com',
    }) as TokenPayload;

    return decoded;
  } catch (error) {
    if (error instanceof jwt.TokenExpiredError) {
      throw new Error('Token expired');
    }
    if (error instanceof jwt.JsonWebTokenError) {
      throw new Error('Invalid token');
    }
    throw error;
  }
}

// ============================================================================
// Decode without Verification (for debugging only)
// ============================================================================

function decodeToken(token: string): TokenPayload | null {
  return jwt.decode(token) as TokenPayload | null;
}

export { generateTokenPair, verifyToken, decodeToken, generateAccessToken, generateRefreshToken };
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.stereotype.Service;

import java.security.*;
import java.security.spec.*;
import java.util.*;

// TokenPayload record
public record TokenPayload(
    String sub,
    String email,
    String role,
    String type,
    String jti
) {}

@Service
public class JwtTokenService {

    private final PrivateKey privateKey;
    private final PublicKey publicKey;

    public JwtTokenService() throws Exception {
        // Load from environment in production
        KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA");
        keyGen.initialize(2048);
        KeyPair pair = keyGen.generateKeyPair();
        this.privateKey = pair.getPrivate();
        this.publicKey  = pair.getPublic();
    }

    /** Generate an access token (15 min) */
    public String generateAccessToken(String userId, String email, String role) {
        return Jwts.builder()
            .subject(userId)
            .claim("email", email)
            .claim("role", role)
            .claim("type", "access")
            .id(UUID.randomUUID().toString())
            .issuer("api.example.com")
            .audience().add("api.example.com").and()
            .issuedAt(new Date())
            .expiration(new Date(System.currentTimeMillis() + 15 * 60 * 1000))
            .signWith(privateKey, Jwts.SIG.RS256)
            .compact();
    }

    /** Generate a refresh token (7 days) */
    public String generateRefreshToken(String userId) {
        return Jwts.builder()
            .subject(userId)
            .claim("type", "refresh")
            .id(UUID.randomUUID().toString())
            .issuer("api.example.com")
            .audience().add("api.example.com").and()
            .issuedAt(new Date())
            .expiration(new Date(System.currentTimeMillis() + 7L * 24 * 60 * 60 * 1000))
            .signWith(privateKey, Jwts.SIG.RS256)
            .compact();
    }

    /** Generate both access and refresh tokens */
    public Map<String, String> generateTokenPair(String userId, String email, String role) {
        return Map.of(
            "accessToken",  generateAccessToken(userId, email, role),
            "refreshToken", generateRefreshToken(userId)
        );
    }

    /** Verify a token and return its claims */
    public Claims verifyToken(String token) {
        try {
            return Jwts.parser()
                .verifyWith(publicKey)
                .requireIssuer("api.example.com")
                .build()
                .parseSignedClaims(token)
                .getPayload();
        } catch (ExpiredJwtException e) {
            throw new RuntimeException("Token expired");
        } catch (JwtException e) {
            throw new RuntimeException("Invalid token");
        }
    }

    /** Decode without verification (debugging only) */
    public Claims decodeToken(String token) {
        return (Claims) Jwts.parser().unsecured().build()
            .parse(token).getPayload();
    }
}
import jwt
import uuid
import os
from datetime import datetime, timedelta, timezone
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
from dataclasses import dataclass
from typing import Optional

# ============================================================
# Token payload dataclass
# ============================================================

@dataclass
class TokenPayload:
    sub: str
    email: str
    role: str
    type: str
    jti: Optional[str] = None
    iat: Optional[int] = None
    exp: Optional[int] = None

# ============================================================
# Key generation (run once; load from env in production)
# ============================================================

def generate_key_pair():
    private_key = rsa.generate_private_key(
        public_exponent=65537,
        key_size=2048,
    )
    return private_key, private_key.public_key()

_private_key, _public_key = generate_key_pair()

PRIVATE_KEY_PEM = os.getenv(
    "JWT_PRIVATE_KEY",
    _private_key.private_bytes(
        serialization.Encoding.PEM,
        serialization.PrivateFormat.PKCS8,
        serialization.NoEncryption(),
    ).decode(),
)
PUBLIC_KEY_PEM = os.getenv(
    "JWT_PUBLIC_KEY",
    _public_key.public_bytes(
        serialization.Encoding.PEM,
        serialization.PublicFormat.SubjectPublicKeyInfo,
    ).decode(),
)

# ============================================================
# Token generation
# ============================================================

def generate_access_token(user_id: str, email: str, role: str) -> str:
    now = datetime.now(timezone.utc)
    payload = {
        "sub": user_id,
        "email": email,
        "role": role,
        "type": "access",
        "jti": str(uuid.uuid4()),
        "iss": "api.example.com",
        "aud": "api.example.com",
        "iat": now,
        "exp": now + timedelta(minutes=15),
    }
    return jwt.encode(payload, PRIVATE_KEY_PEM, algorithm="RS256")

def generate_refresh_token(user_id: str) -> str:
    now = datetime.now(timezone.utc)
    payload = {
        "sub": user_id,
        "type": "refresh",
        "jti": str(uuid.uuid4()),
        "iss": "api.example.com",
        "aud": "api.example.com",
        "iat": now,
        "exp": now + timedelta(days=7),
    }
    return jwt.encode(payload, PRIVATE_KEY_PEM, algorithm="RS256")

def generate_token_pair(user_id: str, email: str, role: str) -> dict:
    return {
        "accessToken":  generate_access_token(user_id, email, role),
        "refreshToken": generate_refresh_token(user_id),
    }

# ============================================================
# Token verification
# ============================================================

def verify_token(token: str) -> dict:
    try:
        return jwt.decode(
            token,
            PUBLIC_KEY_PEM,
            algorithms=["RS256"],
            issuer="api.example.com",
            audience="api.example.com",
        )
    except jwt.ExpiredSignatureError:
        raise ValueError("Token expired")
    except jwt.InvalidTokenError:
        raise ValueError("Invalid token")

def decode_token(token: str) -> dict | None:
    """Decode without verification — debugging only."""
    return jwt.decode(token, options={"verify_signature": False})
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using Microsoft.IdentityModel.Tokens;

// ============================================================
// Token payload record
// ============================================================

public record TokenPayload(
    string Sub,
    string Email,
    string Role,
    string Type,
    string Jti
);

// ============================================================
// JWT Token Service
// ============================================================

public class JwtTokenService
{
    private readonly RsaSecurityKey _privateKey;
    private readonly RsaSecurityKey _publicKey;
    private const string Issuer   = "api.example.com";
    private const string Audience = "api.example.com";

    public JwtTokenService()
    {
        // In production, load PEM from environment variables
        var rsa = RSA.Create(2048);
        _privateKey = new RsaSecurityKey(rsa);
        _publicKey  = new RsaSecurityKey(rsa);
    }

    /// <summary>Generate an access token (15 min).</summary>
    public string GenerateAccessToken(string userId, string email, string role)
    {
        var claims = new[]
        {
            new Claim(JwtRegisteredClaimNames.Sub,   userId),
            new Claim("email", email),
            new Claim("role",  role),
            new Claim("type",  "access"),
            new Claim(JwtRegisteredClaimNames.Jti,   Guid.NewGuid().ToString()),
        };
        return CreateToken(claims, TimeSpan.FromMinutes(15));
    }

    /// <summary>Generate a refresh token (7 days).</summary>
    public string GenerateRefreshToken(string userId)
    {
        var claims = new[]
        {
            new Claim(JwtRegisteredClaimNames.Sub, userId),
            new Claim("type", "refresh"),
            new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
        };
        return CreateToken(claims, TimeSpan.FromDays(7));
    }

    /// <summary>Generate both tokens.</summary>
    public (string AccessToken, string RefreshToken) GenerateTokenPair(
        string userId, string email, string role) =>
        (GenerateAccessToken(userId, email, role), GenerateRefreshToken(userId));

    /// <summary>Verify token and return its ClaimsPrincipal.</summary>
    public ClaimsPrincipal VerifyToken(string token)
    {
        var handler = new JwtSecurityTokenHandler();
        var parameters = new TokenValidationParameters
        {
            ValidateIssuer           = true,
            ValidIssuer              = Issuer,
            ValidateAudience         = true,
            ValidAudience            = Audience,
            ValidateLifetime         = true,
            ValidateIssuerSigningKey = true,
            IssuerSigningKey         = _publicKey,
            ValidAlgorithms          = new[] { SecurityAlgorithms.RsaSha256 },
        };

        try
        {
            return handler.ValidateToken(token, parameters, out _);
        }
        catch (SecurityTokenExpiredException)
        {
            throw new Exception("Token expired");
        }
        catch (SecurityTokenException)
        {
            throw new Exception("Invalid token");
        }
    }

    /// <summary>Decode without verification — debugging only.</summary>
    public JwtSecurityToken? DecodeToken(string token)
    {
        var handler = new JwtSecurityTokenHandler();
        return handler.CanReadToken(token)
            ? handler.ReadJwtToken(token)
            : null;
    }

    private string CreateToken(IEnumerable<Claim> claims, TimeSpan lifetime)
    {
        var credentials = new SigningCredentials(
            _privateKey, SecurityAlgorithms.RsaSha256);

        var descriptor = new SecurityTokenDescriptor
        {
            Subject            = new ClaimsIdentity(claims),
            Issuer             = Issuer,
            Audience           = Audience,
            IssuedAt           = DateTime.UtcNow,
            Expires            = DateTime.UtcNow.Add(lifetime),
            SigningCredentials = credentials,
        };

        var handler = new JwtSecurityTokenHandler();
        return handler.WriteToken(handler.CreateToken(descriptor));
    }
}

Part 4: Token Refresh Pattern with Rotation

The access token is short-lived (15 minutes) to minimize damage if compromised. When it expires, the client uses the refresh token to get a new access token. To prevent replay attacks, we implement refresh token rotation: each refresh issues a new refresh token.

// ============================================================================
// Refresh Token Rotation
// ============================================================================

interface RefreshTokenRequest {
  refreshToken: string;
}

interface AuthResponse {
  accessToken: string;
  refreshToken: string;
}

/**
 * POST /auth/refresh
 * Exchange refresh token for new access + refresh tokens
 */
async function refreshTokens(request: RefreshTokenRequest): Promise<AuthResponse> {
  try {
    // 1. Verify the refresh token
    const decoded = verifyToken(request.refreshToken);

    // 2. Ensure it's actually a refresh token
    if (decoded.type !== 'refresh') {
      throw new Error('Invalid token type');
    }

    // 3. Check if token is revoked (via Redis blacklist)
    const isRevoked = await isTokenRevoked(decoded.jti!);
    if (isRevoked) {
      throw new Error('Refresh token has been revoked');
    }

    // 4. Revoke the old refresh token (prevent replay)
    await revokeToken(decoded.jti!, 7 * 24 * 60 * 60); // 7-day TTL

    // 5. Fetch fresh user data from database
    const user = await db.users.findById(decoded.sub);
    if (!user) {
      throw new Error('User not found');
    }

    // 6. Issue new token pair
    const newTokens = generateTokenPair(user.id, user.email, user.role);

    return newTokens;
  } catch (error) {
    throw new Error(`Token refresh failed: ${error.message}`);
  }
}

/**
 * POST /auth/logout
 * Revoke both tokens
 */
async function logout(accessToken: string, refreshToken: string): Promise<void> {
  const accessDecoded = verifyToken(accessToken);
  const refreshDecoded = verifyToken(refreshToken);

  // Revoke both tokens
  await revokeToken(accessDecoded.jti!, 15 * 60); // Access token TTL
  await revokeToken(refreshDecoded.jti!, 7 * 24 * 60 * 60); // Refresh token TTL
}
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import io.jsonwebtoken.Claims;

@Service
public class TokenRefreshService {

    private final JwtTokenService jwtTokenService;
    private final TokenRevocationService revocationService;
    private final UserRepository userRepository;

    public TokenRefreshService(
        JwtTokenService jwtTokenService,
        TokenRevocationService revocationService,
        UserRepository userRepository
    ) {
        this.jwtTokenService   = jwtTokenService;
        this.revocationService = revocationService;
        this.userRepository    = userRepository;
    }

    /**
     * POST /auth/refresh
     * Exchange refresh token for new access + refresh tokens
     */
    @Transactional
    public Map<String, String> refreshTokens(String refreshToken) {
        // 1. Verify the refresh token
        Claims claims = jwtTokenService.verifyToken(refreshToken);

        // 2. Ensure it's a refresh token
        if (!"refresh".equals(claims.get("type", String.class))) {
            throw new IllegalArgumentException("Invalid token type");
        }

        String jti = claims.getId();

        // 3. Check if revoked
        if (revocationService.isRevoked(jti)) {
            throw new SecurityException("Refresh token has been revoked");
        }

        // 4. Revoke old refresh token
        revocationService.revoke(jti, 7 * 24 * 60 * 60);

        // 5. Fetch fresh user data
        User user = userRepository.findById(claims.getSubject())
            .orElseThrow(() -> new RuntimeException("User not found"));

        // 6. Issue new token pair
        return jwtTokenService.generateTokenPair(user.getId(), user.getEmail(), user.getRole());
    }

    /**
     * POST /auth/logout — revoke both tokens
     */
    public void logout(String accessToken, String refreshToken) {
        Claims accessClaims  = jwtTokenService.verifyToken(accessToken);
        Claims refreshClaims = jwtTokenService.verifyToken(refreshToken);

        revocationService.revoke(accessClaims.getId(),  15 * 60);
        revocationService.revoke(refreshClaims.getId(), 7 * 24 * 60 * 60);
    }
}
from fastapi import HTTPException
from pydantic import BaseModel

class RefreshTokenRequest(BaseModel):
    refresh_token: str

class AuthResponse(BaseModel):
    access_token: str
    refresh_token: str

async def refresh_tokens(request: RefreshTokenRequest) -> AuthResponse:
    """POST /auth/refresh — exchange refresh token for new token pair."""
    # 1. Verify the refresh token
    claims = verify_token(request.refresh_token)

    # 2. Ensure it is a refresh token
    if claims.get("type") != "refresh":
        raise HTTPException(status_code=401, detail="Invalid token type")

    jti = claims["jti"]

    # 3. Check revocation
    if await is_token_revoked(jti):
        raise HTTPException(status_code=401, detail="Refresh token has been revoked")

    # 4. Revoke old refresh token (prevent replay)
    await revoke_token(jti, ttl_seconds=7 * 24 * 60 * 60)

    # 5. Fetch fresh user data
    user = await db.users.find_by_id(claims["sub"])
    if not user:
        raise HTTPException(status_code=401, detail="User not found")

    # 6. Issue new token pair
    tokens = generate_token_pair(user.id, user.email, user.role)
    return AuthResponse(
        access_token=tokens["accessToken"],
        refresh_token=tokens["refreshToken"],
    )

async def logout(access_token: str, refresh_token: str) -> None:
    """POST /auth/logout — revoke both tokens."""
    access_claims  = verify_token(access_token)
    refresh_claims = verify_token(refresh_token)

    await revoke_token(access_claims["jti"],  ttl_seconds=15 * 60)
    await revoke_token(refresh_claims["jti"], ttl_seconds=7 * 24 * 60 * 60)
using Microsoft.AspNetCore.Mvc;

public class RefreshTokenRequest
{
    public string RefreshToken { get; set; } = "";
}

public class AuthResponse
{
    public string AccessToken  { get; set; } = "";
    public string RefreshToken { get; set; } = "";
}

[ApiController]
[Route("auth")]
public class TokenRefreshController : ControllerBase
{
    private readonly JwtTokenService       _jwtService;
    private readonly TokenRevocationService _revocation;
    private readonly IUserRepository        _users;

    public TokenRefreshController(
        JwtTokenService jwtService,
        TokenRevocationService revocation,
        IUserRepository users)
    {
        _jwtService  = jwtService;
        _revocation  = revocation;
        _users       = users;
    }

    /// <summary>POST /auth/refresh — exchange refresh token for new pair.</summary>
    [HttpPost("refresh")]
    public async Task<IActionResult> RefreshTokens([FromBody] RefreshTokenRequest request)
    {
        // 1. Verify refresh token
        var principal = _jwtService.VerifyToken(request.RefreshToken);
        var tokenType = principal.FindFirstValue("type");

        // 2. Ensure it is a refresh token
        if (tokenType != "refresh")
            return Unauthorized(new { error = "Invalid token type" });

        var jti = principal.FindFirstValue(JwtRegisteredClaimNames.Jti)!;

        // 3. Check revocation
        if (await _revocation.IsRevokedAsync(jti))
            return Unauthorized(new { error = "Refresh token has been revoked" });

        // 4. Revoke old token
        await _revocation.RevokeAsync(jti, TimeSpan.FromDays(7));

        // 5. Fetch fresh user data
        var userId = principal.FindFirstValue(ClaimTypes.NameIdentifier)!;
        var user   = await _users.FindByIdAsync(userId)
                     ?? throw new Exception("User not found");

        // 6. Issue new token pair
        var (accessToken, refreshToken) = _jwtService.GenerateTokenPair(
            user.Id, user.Email, user.Role);

        return Ok(new AuthResponse
        {
            AccessToken  = accessToken,
            RefreshToken = refreshToken,
        });
    }

    /// <summary>POST /auth/logout — revoke both tokens.</summary>
    [HttpPost("logout")]
    public async Task<IActionResult> Logout(
        [FromHeader(Name = "Authorization")] string authHeader,
        [FromBody] RefreshTokenRequest request)
    {
        var accessToken = authHeader.Replace("Bearer ", "");
        var accessPrincipal  = _jwtService.VerifyToken(accessToken);
        var refreshPrincipal = _jwtService.VerifyToken(request.RefreshToken);

        await _revocation.RevokeAsync(
            accessPrincipal.FindFirstValue(JwtRegisteredClaimNames.Jti)!,
            TimeSpan.FromMinutes(15));

        await _revocation.RevokeAsync(
            refreshPrincipal.FindFirstValue(JwtRegisteredClaimNames.Jti)!,
            TimeSpan.FromDays(7));

        return Ok(new { message = "Logged out" });
    }
}

Part 5: Auth Middleware for Protected Routes

Here’s Express middleware that validates JWT and attaches user info to the request:

import { Request, Response, NextFunction } from 'express';

// Extend Express Request type
declare global {
  namespace Express {
    interface Request {
      user?: TokenPayload;
    }
  }
}

/**
 * Middleware: Extract and verify JWT from Authorization header
 */
export function authMiddleware(req: Request, res: Response, next: NextFunction) {
  try {
    const authHeader = req.headers.authorization;
    
    if (!authHeader || !authHeader.startsWith('Bearer ')) {
      return res.status(401).json({ error: 'Missing or invalid authorization header' });
    }

    const token = authHeader.slice(7); // Remove "Bearer " prefix

    // Verify token
    const payload = verifyToken(token);

    // Check token type
    if (payload.type !== 'access') {
      return res.status(401).json({ error: 'Invalid token type' });
    }

    // Attach user to request
    req.user = payload;
    next();
  } catch (error) {
    return res.status(401).json({ error: error.message });
  }
}

/**
 * Middleware: Check user role
 */
export function requireRole(...roles: string[]) {
  return (req: Request, res: Response, next: NextFunction) => {
    if (!req.user) {
      return res.status(401).json({ error: 'Authentication required' });
    }

    if (!roles.includes(req.user.role)) {
      return res.status(403).json({ error: 'Insufficient permissions' });
    }

    next();
  };
}

// ============================================================================
// Example Route
// ============================================================================

app.get('/api/profile', authMiddleware, (req: Request, res: Response) => {
  // req.user is now available and verified
  res.json({
    userId: req.user!.sub,
    email: req.user!.email,
    role: req.user!.role,
  });
});

app.delete('/api/admin/users/:id', authMiddleware, requireRole('admin'), (req: Request, res: Response) => {
  // Only admin users can reach here
  res.json({ message: 'User deleted' });
});
import jakarta.servlet.*;
import jakarta.servlet.http.*;
import org.springframework.security.authentication.*;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import io.jsonwebtoken.Claims;

@Component
public class JwtAuthFilter extends OncePerRequestFilter {

    private final JwtTokenService jwtTokenService;

    public JwtAuthFilter(JwtTokenService jwtTokenService) {
        this.jwtTokenService = jwtTokenService;
    }

    @Override
    protected void doFilterInternal(
        HttpServletRequest request,
        HttpServletResponse response,
        FilterChain chain
    ) throws ServletException, java.io.IOException {

        String authHeader = request.getHeader("Authorization");

        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            chain.doFilter(request, response);
            return;
        }

        String token = authHeader.substring(7);

        try {
            Claims claims = jwtTokenService.verifyToken(token);

            if (!"access".equals(claims.get("type", String.class))) {
                response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid token type");
                return;
            }

            // Build Spring Security authentication object
            var auth = new UsernamePasswordAuthenticationToken(
                claims.getSubject(),
                null,
                List.of(new SimpleGrantedAuthority("ROLE_" + claims.get("role", String.class).toUpperCase()))
            );
            SecurityContextHolder.getContext().setAuthentication(auth);
            chain.doFilter(request, response);

        } catch (Exception e) {
            response.sendError(HttpServletResponse.SC_UNAUTHORIZED, e.getMessage());
        }
    }
}

// Role-based access via Spring Security annotations:
@RestController
@RequestMapping("/api")
public class ProfileController {

    @GetMapping("/profile")
    public ResponseEntity<?> getProfile(Authentication auth) {
        return ResponseEntity.ok(Map.of(
            "userId", auth.getName(),
            "role",   auth.getAuthorities().iterator().next().getAuthority()
        ));
    }

    @DeleteMapping("/admin/users/{id}")
    @PreAuthorize("hasRole('ADMIN')")
    public ResponseEntity<?> deleteUser(@PathVariable String id) {
        return ResponseEntity.ok(Map.of("message", "User deleted"));
    }
}
from fastapi import Depends, HTTPException, Security
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials

bearer_scheme = HTTPBearer()

async def get_current_user(
    credentials: HTTPAuthorizationCredentials = Security(bearer_scheme),
) -> dict:
    """FastAPI dependency: verify JWT and return payload."""
    token = credentials.credentials
    try:
        payload = verify_token(token)
    except ValueError as exc:
        raise HTTPException(status_code=401, detail=str(exc))

    if payload.get("type") != "access":
        raise HTTPException(status_code=401, detail="Invalid token type")

    return payload

def require_role(*roles: str):
    """Dependency factory for role-based access."""
    async def check(user: dict = Depends(get_current_user)):
        if user.get("role") not in roles:
            raise HTTPException(status_code=403, detail="Insufficient permissions")
        return user
    return check

# ---- Example routes ----

from fastapi import FastAPI
app = FastAPI()

@app.get("/api/profile")
async def get_profile(user: dict = Depends(get_current_user)):
    return {"userId": user["sub"], "email": user["email"], "role": user["role"]}

@app.delete("/api/admin/users/{user_id}")
async def delete_user(
    user_id: str,
    _admin: dict = Depends(require_role("admin")),
):
    return {"message": "User deleted"}
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.IdentityModel.Tokens.Jwt;

// --- Middleware ---

public class JwtAuthMiddleware
{
    private readonly RequestDelegate _next;
    private readonly JwtTokenService _jwtService;

    public JwtAuthMiddleware(RequestDelegate next, JwtTokenService jwtService)
    {
        _next       = next;
        _jwtService = jwtService;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        var authHeader = context.Request.Headers["Authorization"].FirstOrDefault();

        if (authHeader?.StartsWith("Bearer ") == true)
        {
            var token = authHeader["Bearer ".Length..];
            try
            {
                var principal = _jwtService.VerifyToken(token);
                var tokenType = principal.FindFirstValue("type");

                if (tokenType == "access")
                    context.User = principal;
                else
                {
                    context.Response.StatusCode = 401;
                    await context.Response.WriteAsJsonAsync(new { error = "Invalid token type" });
                    return;
                }
            }
            catch
            {
                context.Response.StatusCode = 401;
                await context.Response.WriteAsJsonAsync(new { error = "Invalid token" });
                return;
            }
        }

        await _next(context);
    }
}

// --- Controllers using [Authorize] ---

[ApiController]
[Route("api")]
public class ProfileController : ControllerBase
{
    [HttpGet("profile")]
    [Authorize]
    public IActionResult GetProfile()
    {
        var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
        var role   = User.FindFirstValue("role");
        return Ok(new { userId, role });
    }

    [HttpDelete("admin/users/{id}")]
    [Authorize(Roles = "admin")]
    public IActionResult DeleteUser(string id)
    {
        return Ok(new { message = "User deleted" });
    }
}

Part 6: Token Storage Comparison

Where you store the token on the client side significantly impacts security:

Storage MethodSecurityAccessibilityXSS RiskCSRF RiskNotes
httpOnly Cookie✅ High✅ Automatic✅ Safe⚠️ RiskBest for web apps. Immune to XSS but vulnerable to CSRF. Use SameSite attribute.
localStorage⚠️ Medium✅ Easy❌ Unsafe✅ SafeVulnerable to XSS injection. JavaScript can read it. Avoid storing sensitive tokens.
sessionStorage⚠️ Medium✅ Easy❌ Unsafe✅ SafeSame as localStorage, cleared when tab closes. Still vulnerable to XSS.
Memory✅ High⚠️ Lost on reload✅ Safe✅ SafeSafest option. Token lost on page refresh—requires silent refresh pattern.
/**
 * Set token in httpOnly cookie
 */
function setTokenCookie(res: Response, token: string, maxAge: number) {
  res.cookie('accessToken', token, {
    httpOnly: true,          // Inaccessible to JavaScript
    secure: true,            // HTTPS only
    sameSite: 'strict',      // CSRF protection
    maxAge: maxAge * 1000,   // Milliseconds
    path: '/',
  });
}

/**
 * Example login route
 */
app.post('/auth/login', async (req: Request, res: Response) => {
  const { email, password } = req.body;

  // Authenticate user...
  const user = await authenticateUser(email, password);
  if (!user) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  // Generate tokens
  const { accessToken, refreshToken } = generateTokenPair(user.id, user.email, user.role);

  // Set access token in httpOnly cookie (15m)
  setTokenCookie(res, accessToken, 15 * 60);

  // Set refresh token in httpOnly cookie (7d)
  res.cookie('refreshToken', refreshToken, {
    httpOnly: true,
    secure: true,
    sameSite: 'strict',
    maxAge: 7 * 24 * 60 * 60 * 1000,
    path: '/',
  });

  res.json({
    user: {
      id: user.id,
      email: user.email,
      role: user.role,
    },
  });
});

/**
 * Middleware: Extract JWT from httpOnly cookie
 */
export function cookieAuthMiddleware(req: Request, res: Response, next: NextFunction) {
  try {
    const token = req.cookies.accessToken;

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

    const payload = verifyToken(token);
    req.user = payload;
    next();
  } catch (error) {
    return res.status(401).json({ error: 'Invalid token' });
  }
}
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/auth")
public class AuthController {

    private final JwtTokenService jwtTokenService;
    private final UserService     userService;

    public AuthController(JwtTokenService jwtTokenService, UserService userService) {
        this.jwtTokenService = jwtTokenService;
        this.userService     = userService;
    }

    private void setTokenCookie(
        HttpServletResponse response,
        String name,
        String value,
        int maxAgeSeconds
    ) {
        Cookie cookie = new Cookie(name, value);
        cookie.setHttpOnly(true);
        cookie.setSecure(true);
        cookie.setPath("/");
        cookie.setMaxAge(maxAgeSeconds);
        // SameSite requires ResponseCookie in Spring:
        response.addHeader("Set-Cookie",
            String.format("%s=%s; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=%d",
                name, value, maxAgeSeconds));
    }

    @PostMapping("/login")
    public ResponseEntity<?> login(
        @RequestBody LoginRequest body,
        HttpServletResponse response
    ) {
        User user = userService.authenticate(body.email(), body.password());
        if (user == null) {
            return ResponseEntity.status(401).body(Map.of("error", "Invalid credentials"));
        }

        var tokens = jwtTokenService.generateTokenPair(user.getId(), user.getEmail(), user.getRole());

        setTokenCookie(response, "accessToken",  tokens.get("accessToken"),  15 * 60);
        setTokenCookie(response, "refreshToken", tokens.get("refreshToken"), 7 * 24 * 60 * 60);

        return ResponseEntity.ok(Map.of("user", Map.of(
            "id",    user.getId(),
            "email", user.getEmail(),
            "role",  user.getRole()
        )));
    }
}

// Cookie-based JWT filter — reads from cookie instead of Authorization header
@Component
public class CookieJwtAuthFilter extends OncePerRequestFilter {
    private final JwtTokenService jwtTokenService;

    public CookieJwtAuthFilter(JwtTokenService jwtTokenService) {
        this.jwtTokenService = jwtTokenService;
    }

    @Override
    protected void doFilterInternal(
        HttpServletRequest request,
        HttpServletResponse response,
        FilterChain chain
    ) throws ServletException, java.io.IOException {
        if (request.getCookies() != null) {
            Arrays.stream(request.getCookies())
                .filter(c -> "accessToken".equals(c.getName()))
                .findFirst()
                .ifPresent(cookie -> {
                    try {
                        Claims claims = jwtTokenService.verifyToken(cookie.getValue());
                        var auth = new UsernamePasswordAuthenticationToken(
                            claims.getSubject(), null,
                            List.of(new SimpleGrantedAuthority(
                                "ROLE_" + claims.get("role", String.class).toUpperCase())));
                        SecurityContextHolder.getContext().setAuthentication(auth);
                    } catch (Exception ignored) {}
                });
        }
        chain.doFilter(request, response);
    }
}
from fastapi import FastAPI, Request, Response, HTTPException, Depends
from fastapi.responses import JSONResponse

app = FastAPI()

def set_token_cookie(response: Response, name: str, value: str, max_age: int) -> None:
    response.set_cookie(
        key=name,
        value=value,
        httponly=True,
        secure=True,
        samesite="strict",
        max_age=max_age,
        path="/",
    )

@app.post("/auth/login")
async def login(body: dict, response: Response):
    user = await authenticate_user(body["email"], body["password"])
    if not user:
        raise HTTPException(status_code=401, detail="Invalid credentials")

    tokens = generate_token_pair(user.id, user.email, user.role)

    set_token_cookie(response, "accessToken",  tokens["accessToken"],  15 * 60)
    set_token_cookie(response, "refreshToken", tokens["refreshToken"], 7 * 24 * 60 * 60)

    return {"user": {"id": user.id, "email": user.email, "role": user.role}}

async def cookie_auth(request: Request) -> dict:
    """Dependency: extract and verify JWT from httpOnly cookie."""
    token = request.cookies.get("accessToken")
    if not token:
        raise HTTPException(status_code=401, detail="No token provided")
    try:
        return verify_token(token)
    except ValueError:
        raise HTTPException(status_code=401, detail="Invalid token")

@app.get("/api/profile")
async def get_profile(user: dict = Depends(cookie_auth)):
    return {"userId": user["sub"], "email": user["email"]}
using Microsoft.AspNetCore.Mvc;

[ApiController]
[Route("auth")]
public class CookieAuthController : ControllerBase
{
    private readonly JwtTokenService _jwtService;
    private readonly IUserService    _userService;

    public CookieAuthController(JwtTokenService jwtService, IUserService userService)
    {
        _jwtService  = jwtService;
        _userService = userService;
    }

    private void SetTokenCookie(string name, string value, int maxAgeSeconds)
    {
        Response.Cookies.Append(name, value, new CookieOptions
        {
            HttpOnly  = true,
            Secure    = true,
            SameSite  = SameSiteMode.Strict,
            MaxAge    = TimeSpan.FromSeconds(maxAgeSeconds),
            Path      = "/",
        });
    }

    [HttpPost("login")]
    public async Task<IActionResult> Login([FromBody] LoginRequest body)
    {
        var user = await _userService.AuthenticateAsync(body.Email, body.Password);
        if (user is null)
            return Unauthorized(new { error = "Invalid credentials" });

        var (accessToken, refreshToken) = _jwtService.GenerateTokenPair(
            user.Id, user.Email, user.Role);

        SetTokenCookie("accessToken",  accessToken,  15 * 60);
        SetTokenCookie("refreshToken", refreshToken, 7 * 24 * 60 * 60);

        return Ok(new { user = new { user.Id, user.Email, user.Role } });
    }
}

// Cookie-based JWT middleware
public class CookieJwtMiddleware
{
    private readonly RequestDelegate _next;
    private readonly JwtTokenService _jwtService;

    public CookieJwtMiddleware(RequestDelegate next, JwtTokenService jwtService)
    {
        _next       = next;
        _jwtService = jwtService;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        if (context.Request.Cookies.TryGetValue("accessToken", out var token))
        {
            try
            {
                context.User = _jwtService.VerifyToken(token);
            }
            catch { /* invalid token — leave context.User as anonymous */ }
        }
        await _next(context);
    }
}

Part 7: Token Revocation Strategies

JWTs are stateless, but sometimes you need to revoke them immediately (logout, password change, role change). Three strategies:

Strategy 1: Token Blacklist with Redis

Store revoked token IDs in Redis with TTL equal to token expiration.

import redis from 'redis';

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

/**
 * Revoke a token by adding its JTI to the blacklist
 */
async function revokeToken(jti: string, ttlSeconds: number): Promise<void> {
  await redisClient.setEx(
    `jwt:blacklist:${jti}`,
    ttlSeconds,
    'revoked'
  );
}

/**
 * Check if a token is revoked
 */
async function isTokenRevoked(jti: string): Promise<boolean> {
  const result = await redisClient.get(`jwt:blacklist:${jti}`);
  return result !== null;
}

/**
 * Enhanced verifyToken with blacklist check
 */
async function verifyTokenWithRevocation(token: string): Promise<TokenPayload> {
  const payload = verifyToken(token); // Initial verification

  // Check if token is revoked
  if (payload.jti && await isTokenRevoked(payload.jti)) {
    throw new Error('Token has been revoked');
  }

  return payload;
}
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;

@Service
public class TokenRevocationService {

    private static final String BLACKLIST_PREFIX = "jwt:blacklist:";
    private final StringRedisTemplate redis;

    public TokenRevocationService(StringRedisTemplate redis) {
        this.redis = redis;
    }

    /** Revoke a token by adding its JTI to the blacklist. */
    public void revoke(String jti, long ttlSeconds) {
        redis.opsForValue().set(
            BLACKLIST_PREFIX + jti,
            "revoked",
            Duration.ofSeconds(ttlSeconds)
        );
    }

    /** Check whether a token is revoked. */
    public boolean isRevoked(String jti) {
        return Boolean.TRUE.equals(redis.hasKey(BLACKLIST_PREFIX + jti));
    }

    /** Verify token and check revocation. */
    public Claims verifyTokenWithRevocation(String token, JwtTokenService jwtService) {
        Claims payload = jwtService.verifyToken(token);
        String jti     = payload.getId();

        if (jti != null && isRevoked(jti)) {
            throw new SecurityException("Token has been revoked");
        }
        return payload;
    }
}
import redis.asyncio as aioredis
import os

redis_client = aioredis.from_url(
    os.getenv("REDIS_URL", "redis://localhost:6379")
)

BLACKLIST_PREFIX = "jwt:blacklist:"

async def revoke_token(jti: str, ttl_seconds: int) -> None:
    """Add a token JTI to the Redis blacklist."""
    await redis_client.setex(f"{BLACKLIST_PREFIX}{jti}", ttl_seconds, "revoked")

async def is_token_revoked(jti: str) -> bool:
    """Return True if the token has been revoked."""
    result = await redis_client.get(f"{BLACKLIST_PREFIX}{jti}")
    return result is not None

async def verify_token_with_revocation(token: str) -> dict:
    """Verify token and check Redis blacklist."""
    payload = verify_token(token)
    jti = payload.get("jti")

    if jti and await is_token_revoked(jti):
        raise ValueError("Token has been revoked")

    return payload
using Microsoft.Extensions.Caching.StackExchangeRedis;
using StackExchange.Redis;

public class TokenRevocationService
{
    private readonly IConnectionMultiplexer _redis;
    private const string Prefix = "jwt:blacklist:";

    public TokenRevocationService(IConnectionMultiplexer redis)
    {
        _redis = redis;
    }

    /// <summary>Add a JTI to the Redis blacklist.</summary>
    public async Task RevokeAsync(string jti, TimeSpan ttl)
    {
        var db = _redis.GetDatabase();
        await db.StringSetAsync(Prefix + jti, "revoked", ttl);
    }

    /// <summary>Return true if the token is revoked.</summary>
    public async Task<bool> IsRevokedAsync(string jti)
    {
        var db = _redis.GetDatabase();
        return await db.KeyExistsAsync(Prefix + jti);
    }

    /// <summary>Verify token and check revocation.</summary>
    public async Task<ClaimsPrincipal> VerifyTokenWithRevocationAsync(
        string token, JwtTokenService jwtService)
    {
        var principal = jwtService.VerifyToken(token);
        var jti       = principal.FindFirstValue(JwtRegisteredClaimNames.Jti);

        if (jti is not null && await IsRevokedAsync(jti))
            throw new SecurityTokenException("Token has been revoked");

        return principal;
    }
}

Pros: Immediate revocation, fine-grained control Cons: Requires Redis, defeats “stateless” advantage

Strategy 2: Token Versioning

Increment a version number when a user’s permissions change. Include version in token.

interface TokenPayload {
  sub: string;
  version: number; // User's current version
  // ...
}

/**
 * When user updates password or role, increment their version
 */
async function incrementUserVersion(userId: string): Promise<number> {
  const user = await db.users.findById(userId);
  const newVersion = (user.tokenVersion || 0) + 1;
  await db.users.update(userId, { tokenVersion: newVersion });
  return newVersion;
}

/**
 * Verify token version matches current user version
 */
async function verifyTokenVersion(payload: TokenPayload): Promise<void> {
  const user = await db.users.findById(payload.sub);
  if (user.tokenVersion !== payload.version) {
    throw new Error('Token version mismatch—user has revoked tokens');
  }
}
import org.springframework.stereotype.Service;

@Service
public class TokenVersionService {

    private final UserRepository userRepository;

    public TokenVersionService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    /** Increment token version — invalidates all existing tokens for this user. */
    public int incrementUserVersion(String userId) {
        User user = userRepository.findById(userId)
            .orElseThrow(() -> new RuntimeException("User not found"));
        int newVersion = (user.getTokenVersion() == null ? 0 : user.getTokenVersion()) + 1;
        user.setTokenVersion(newVersion);
        userRepository.save(user);
        return newVersion;
    }

    /** Verify the token version matches the stored version. */
    public void verifyTokenVersion(Claims claims) {
        int tokenVersion = claims.get("version", Integer.class);
        User user = userRepository.findById(claims.getSubject())
            .orElseThrow(() -> new RuntimeException("User not found"));

        if (!Objects.equals(user.getTokenVersion(), tokenVersion)) {
            throw new SecurityException("Token version mismatch — user has revoked tokens");
        }
    }
}
from dataclasses import dataclass

@dataclass
class TokenPayload:
    sub: str
    version: int
    # ...

async def increment_user_version(user_id: str) -> int:
    """Increment token version, invalidating all existing tokens for the user."""
    user = await db.users.find_by_id(user_id)
    new_version = (user.token_version or 0) + 1
    await db.users.update(user_id, {"token_version": new_version})
    return new_version

async def verify_token_version(payload: dict) -> None:
    """Raise if the token version does not match the current user version."""
    user = await db.users.find_by_id(payload["sub"])
    if user.token_version != payload.get("version"):
        raise ValueError("Token version mismatch — user has revoked tokens")
public class TokenVersionService
{
    private readonly IUserRepository _users;

    public TokenVersionService(IUserRepository users)
    {
        _users = users;
    }

    /// <summary>Increment token version, invalidating all existing tokens.</summary>
    public async Task<int> IncrementUserVersionAsync(string userId)
    {
        var user = await _users.FindByIdAsync(userId)
                   ?? throw new Exception("User not found");

        user.TokenVersion = (user.TokenVersion ?? 0) + 1;
        await _users.UpdateAsync(user);
        return user.TokenVersion.Value;
    }

    /// <summary>Verify the token version matches the stored user version.</summary>
    public async Task VerifyTokenVersionAsync(ClaimsPrincipal principal)
    {
        var userId = principal.FindFirstValue(ClaimTypes.NameIdentifier)!;
        var tokenVersion = int.Parse(principal.FindFirstValue("version") ?? "0");

        var user = await _users.FindByIdAsync(userId)
                   ?? throw new Exception("User not found");

        if (user.TokenVersion != tokenVersion)
            throw new SecurityTokenException("Token version mismatch — user has revoked tokens");
    }
}

Pros: Simple, no external storage, instantaneous Cons: All old tokens are invalidated for that user, less granular

Strategy 3: Short Expiry

Accept that some tokens remain valid briefly after revocation. Use short TTL (15 minutes for access tokens).

// No additional code needed—just use short-lived tokens
// Combined with refresh token rotation, this provides good coverage
// No additional code needed—just configure short expiry in JwtTokenService
// Access token: 15 minutes, refresh token: 7 days
// Combined with refresh token rotation, this provides good coverage
# No additional code needed—just use short-lived tokens
# Access token: timedelta(minutes=15), refresh token: timedelta(days=7)
# Combined with refresh token rotation, this provides good coverage
// No additional code needed—just configure short expiry in JwtTokenService
// Access token: TimeSpan.FromMinutes(15), refresh: TimeSpan.FromDays(7)
// Combined with refresh token rotation, this provides good coverage

Pros: Stateless, simple, works well in practice Cons: Brief window where revoked token is still valid

Recommendation: Use short expiry for access tokens + refresh token rotation. Add Redis blacklist only if you need immediate logout across all devices.

Part 8: Common JWT Security Pitfalls

❌ Pitfall 1: Storing PII in the Payload

// DON'T DO THIS
const payload = {
  sub: user.id,
  email: user.email,
  password: user.passwordHash, // ❌ Storing password!
  ssn: '123-45-6789',            // ❌ PII in token!
  creditCard: '4111-1111-1111-1111', // ❌ Financial data!
};
// DON'T DO THIS
Map<String, Object> claims = new HashMap<>();
claims.put("sub",        user.getId());
claims.put("email",      user.getEmail());
claims.put("password",   user.getPasswordHash()); // ❌ Storing password!
claims.put("ssn",        "123-45-6789");           // ❌ PII in token!
claims.put("creditCard", "4111-1111-1111-1111");   // ❌ Financial data!
# DON'T DO THIS
payload = {
    "sub":         user.id,
    "email":       user.email,
    "password":    user.password_hash,   # ❌ Storing password!
    "ssn":         "123-45-6789",        # ❌ PII in token!
    "credit_card": "4111-1111-1111-1111", # ❌ Financial data!
}
// DON'T DO THIS
var claims = new[]
{
    new Claim(ClaimTypes.NameIdentifier, user.Id),
    new Claim("email",       user.Email),
    new Claim("password",    user.PasswordHash),   // ❌ Storing password!
    new Claim("ssn",         "123-45-6789"),       // ❌ PII in token!
    new Claim("creditCard",  "4111-1111-1111-1111"), // ❌ Financial data!
};

Why: JWTs are encoded, not encrypted. Anyone can decode the payload. This data would be exposed in logs, browser history, and CDN caches.

Fix: Store only identifiers. Fetch sensitive data from the database when needed.

// DO THIS
const payload = {
  sub: user.id,        // User ID only
  role: user.role,     // Non-sensitive metadata
  type: 'access',
};

// When you need user email in a protected route:
app.get('/api/profile', authMiddleware, async (req: Request, res: Response) => {
  const user = await db.users.findById(req.user!.sub);
  res.json({ email: user.email }); // Fetch from DB
});
// DO THIS — store only identifiers
Map<String, Object> claims = new HashMap<>();
claims.put("sub",  user.getId());
claims.put("role", user.getRole());
claims.put("type", "access");

// Fetch sensitive data on demand
@GetMapping("/api/profile")
public ResponseEntity<?> getProfile(Authentication auth) {
    User user = userRepository.findById(auth.getName())
        .orElseThrow();
    return ResponseEntity.ok(Map.of("email", user.getEmail()));
}
# DO THIS — store only identifiers
payload = {
    "sub":  user.id,
    "role": user.role,
    "type": "access",
}

# Fetch sensitive data on demand
@app.get("/api/profile")
async def get_profile(current_user: dict = Depends(get_current_user)):
    user = await db.users.find_by_id(current_user["sub"])
    return {"email": user.email}  # Fetch from DB
// DO THIS — store only identifiers
var claims = new[]
{
    new Claim(ClaimTypes.NameIdentifier, user.Id),
    new Claim("role", user.Role),
    new Claim("type", "access"),
};

// Fetch sensitive data on demand
[HttpGet("api/profile")]
[Authorize]
public async Task<IActionResult> GetProfile()
{
    var userId = User.FindFirstValue(ClaimTypes.NameIdentifier)!;
    var user   = await _users.FindByIdAsync(userId);
    return Ok(new { email = user!.Email }); // Fetch from DB
}

❌ Pitfall 2: Algorithm Confusion Attack

An attacker changes alg from RS256 to HS256, then signs with the public key (which they can access).

// Vulnerable: accepts any algorithm
jwt.verify(token, publicKey); // ❌

// Safe: whitelist algorithms
jwt.verify(token, publicKey, {
  algorithms: ['RS256'], // ✅ Only RS256 accepted
});
// Vulnerable: no algorithm whitelist
Jwts.parser().verifyWith(publicKey).build().parseSignedClaims(token); // relies on header alg ❌

// Safe: enforce RS256 explicitly
Jwts.parser()
    .verifyWith(publicKey)
    .build()
    .parseSignedClaims(token); // jjwt 0.12+ ignores "none" and HS256 when PublicKey is supplied ✅
// Extra safety — validate the algorithm claim manually:
String alg = (String) Jwts.parser().unsecured().build()
    .parse(token).getHeader().get("alg");
if (!"RS256".equals(alg)) throw new SecurityException("Algorithm not allowed");
# Vulnerable: accepts any algorithm
jwt.decode(token, public_key)  # ❌

# Safe: whitelist algorithms
jwt.decode(
    token,
    PUBLIC_KEY_PEM,
    algorithms=["RS256"],  # ✅ Only RS256 accepted
)
// Vulnerable: no algorithm whitelist
var parameters = new TokenValidationParameters
{
    IssuerSigningKey = publicKey,
    // Missing ValidAlgorithms ❌
};

// Safe: enforce RS256
var safeParameters = new TokenValidationParameters
{
    IssuerSigningKey  = publicKey,
    ValidAlgorithms   = new[] { SecurityAlgorithms.RsaSha256 }, // ✅
};

❌ Pitfall 3: Using Weak Secrets with HS256

// ❌ WEAK
const secret = 'secret123';
const token = jwt.sign(payload, secret, { algorithm: 'HS256' });

// ✅ STRONG (use a cryptographically secure random string)
const secret = crypto.randomBytes(32).toString('hex');
// ❌ WEAK
SecretKey weakKey = Keys.hmacShaKeyFor("secret123".getBytes());

// ✅ STRONG — at least 256 bits of random data
SecretKey strongKey = Keys.secretKeyFor(SignatureAlgorithm.HS256);
// Or load from env:
// SecretKey strongKey = Keys.hmacShaKeyFor(
//     Base64.getDecoder().decode(System.getenv("JWT_SECRET")));
# ❌ WEAK
secret = "secret123"
token  = jwt.encode(payload, secret, algorithm="HS256")

# ✅ STRONG — at least 32 bytes of random data
import secrets
strong_secret = secrets.token_hex(32)
// ❌ WEAK
var weakKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("secret123"));

// ✅ STRONG — 256-bit random key
var strongKeyBytes = new byte[32];
RandomNumberGenerator.Fill(strongKeyBytes);
var strongKey = new SymmetricSecurityKey(strongKeyBytes);

With HS256, the secret must be cryptographically strong. Use at least 32 bytes of random data.

❌ Pitfall 4: Not Validating Token Expiration

// ❌ Manual expiry check (incomplete)
const payload = jwt.decode(token); // Doesn't verify expiry!
if (payload.exp < Date.now() / 1000) {
  // Too late—token already used!
}

// ✅ jwt.verify handles expiry automatically
jwt.verify(token, secret); // Throws if expired
// ❌ Manual expiry check — unreliable
Claims claims = Jwts.parser().unsecured().build()
    .parse(token).getPayload(); // No expiry check!
if (claims.getExpiration().before(new Date())) {
    // Token was already used before this check
}

// ✅ jjwt verifies expiry automatically during parseSignedClaims
Claims verified = Jwts.parser()
    .verifyWith(publicKey)
    .build()
    .parseSignedClaims(token)  // Throws ExpiredJwtException if expired ✅
    .getPayload();
# ❌ Manual expiry check — unreliable
payload = jwt.decode(token, options={"verify_exp": False})  # Skips expiry!
if payload["exp"] < time.time():
    pass  # Token already used before this check

# ✅ PyJWT verifies expiry by default
payload = jwt.decode(token, PUBLIC_KEY_PEM, algorithms=["RS256"])  # Raises ExpiredSignatureError ✅
// ❌ Manual expiry — handler reads token but does not validate
var handler = new JwtSecurityTokenHandler();
var insecure = handler.ReadJwtToken(token);  // No expiry validation!
if (insecure.ValidTo < DateTime.UtcNow) { /* already used */ }

// ✅ ValidateToken checks expiry automatically
handler.ValidateToken(token, new TokenValidationParameters
{
    ValidateLifetime = true,   // ✅ Throws SecurityTokenExpiredException if expired
    IssuerSigningKey = publicKey,
}, out _);

❌ Pitfall 5: Ignoring the exp Claim

// ❌ DON'T create tokens without expiration
jwt.sign(payload, secret); // No expiry!

// ✅ Always set expiration
jwt.sign(payload, secret, {
  expiresIn: '15m', // Access token
});
// ❌ No expiration
Jwts.builder()
    .subject(userId)
    .signWith(privateKey, Jwts.SIG.RS256)
    .compact(); // No expiry! ❌

// ✅ Always set expiration
Jwts.builder()
    .subject(userId)
    .expiration(new Date(System.currentTimeMillis() + 15 * 60 * 1000)) // ✅
    .signWith(privateKey, Jwts.SIG.RS256)
    .compact();
# ❌ No expiration
payload = {"sub": user_id}
jwt.encode(payload, secret, algorithm="HS256")  # No expiry! ❌

# ✅ Always set expiration
from datetime import datetime, timedelta, timezone
payload = {
    "sub": user_id,
    "exp": datetime.now(timezone.utc) + timedelta(minutes=15),  # ✅
}
jwt.encode(payload, secret, algorithm="HS256")
// ❌ No expiration
var descriptor = new SecurityTokenDescriptor
{
    Subject            = new ClaimsIdentity(claims),
    SigningCredentials = creds,
    // Missing Expires ❌
};

// ✅ Always set expiration
var safeDescriptor = new SecurityTokenDescriptor
{
    Subject            = new ClaimsIdentity(claims),
    Expires            = DateTime.UtcNow.AddMinutes(15), // ✅
    SigningCredentials = creds,
};

❌ Pitfall 6: Storing Tokens in URL Parameters

// ❌ BAD
window.location.href = `/callback?token=${jwtToken}`;
// Token appears in:
// - Browser history
// - Server logs
// - Referrer headers
// - CDN logs

// ✅ GOOD: Use httpOnly cookies or POST request
// ❌ BAD — redirecting with token in URL
// response.sendRedirect("/callback?token=" + jwtToken);
// Token appears in server logs, referrer headers, CDN logs

// ✅ GOOD — set as httpOnly cookie, never expose in URL
Cookie cookie = new Cookie("accessToken", jwtToken);
cookie.setHttpOnly(true);
cookie.setSecure(true);
cookie.setPath("/");
response.addCookie(cookie);
response.sendRedirect("/callback");
# ❌ BAD — redirecting with token in URL
# return RedirectResponse(url=f"/callback?token={jwt_token}")
# Token appears in server logs, referrer headers, CDN logs

# ✅ GOOD — set as httpOnly cookie, never expose in URL
from fastapi.responses import RedirectResponse

response = RedirectResponse(url="/callback")
response.set_cookie(
    key="accessToken",
    value=jwt_token,
    httponly=True,
    secure=True,
    samesite="strict",
)
// ❌ BAD — redirecting with token in URL
// return Redirect($"/callback?token={jwtToken}");
// Token appears in server logs, referrer headers, CDN logs

// ✅ GOOD — set as httpOnly cookie, never expose in URL
Response.Cookies.Append("accessToken", jwtToken, new CookieOptions
{
    HttpOnly = true,
    Secure   = true,
    SameSite = SameSiteMode.Strict,
    Path     = "/",
});
return Redirect("/callback");

Part 9: JWT vs Session Cookies

FeatureJWTSession Cookie
Stateless✅ Yes❌ No (requires storage)
Revocation⚠️ Hard✅ Easy (delete from DB)
Scalability✅ Excellent (no server state)⚠️ Requires shared session store
CSRF Protection✅ Easier (no cookie)❌ Requires CSRF token
Performance✅ Fast (no DB lookup)⚠️ Requires session lookup
Across Domains✅ Works (CORS)❌ Blocked by SameSite
Mobile Apps✅ Native❌ Limited support
Refresh Pattern✅ Built-in (refresh tokens)❌ Implicit
Data Size⚠️ Grows with claims✅ Minimal (just ID)

Use JWT when:

  • Building APIs consumed by multiple clients
  • Mobile apps with native clients
  • Microservices architecture
  • Single Sign-On (SSO)

Use Session Cookies when:

  • Traditional server-rendered web apps
  • Simple monolithic architecture
  • Easier revocation is critical
  • Lower complexity preferred

Part 10: Production Checklist

Before shipping JWT authentication to production:

Security

  • Use RS256 or ES256 (asymmetric) for distributed systems
  • If using HS256, ensure secret is ≥32 bytes of random data
  • Store secrets in environment variables, never in code
  • Use httpOnly, Secure, SameSite cookies
  • Validate and whitelist algorithm in jwt.verify()
  • Set short expiration for access tokens (15m)
  • Use refresh token rotation
  • Don’t store PII, passwords, or financial data in payload
  • Implement token revocation (Redis blacklist or versioning)
  • HTTPS only—never send JWTs over HTTP
  • Validate iss (issuer) and aud (audience) claims

Implementation

  • Handle token expiration errors gracefully
  • Implement automatic token refresh on the client
  • Test middleware with expired/invalid/malformed tokens
  • Log authentication failures (suspect repeated failures)
  • Monitor for token refresh abuse (indicator of refresh token theft)
  • Implement rate limiting on /auth/login and /auth/refresh
  • Add CORS validation if serving cross-origin requests

Operational

  • Document secret rotation procedure
  • Monitor Redis memory if using blacklist strategy
  • Set up alerts for unusual token refresh rates
  • Plan key rotation (annual minimum for RSA keys)
  • Test logout flow across all clients
  • Verify tokens are invalidated after password change
  • Test in staging with production TTLs

Testing

// Test: Expired token is rejected
test('rejects expired token', () => {
  const token = jwt.sign(payload, secret, { expiresIn: '0s' });
  sleep(1000);
  expect(() => jwt.verify(token, secret)).toThrow('expired');
});

// Test: Token with wrong algorithm is rejected
test('rejects token signed with wrong algorithm', () => {
  const token = jwt.sign(payload, secret, { algorithm: 'HS256' });
  expect(() => jwt.verify(token, secret, { algorithms: ['RS256'] }))
    .toThrow('invalid');
});

// Test: Invalid signature is rejected
test('rejects token with invalid signature', () => {
  const token = jwt.sign(payload, secret) + 'tampered';
  expect(() => jwt.verify(token, secret)).toThrow('invalid');
});

// Test: Refresh token rotation works
test('revokes old refresh token after rotation', async () => {
  const oldToken = generateRefreshToken(userId);
  const newTokens = await refreshTokens({ refreshToken: oldToken });
  
  // Old token should be blacklisted
  expect(await isTokenRevoked(decodeToken(oldToken)!.jti!)).toBe(true);
  // New refresh token should work
  expect(() => jwt.verify(newTokens.refreshToken, PUBLIC_KEY)).not.toThrow();
});
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;

@SpringBootTest
class JwtTokenServiceTest {

    @Autowired JwtTokenService jwtService;
    @Autowired TokenRevocationService revocationService;

    @Test
    void rejectsExpiredToken() throws InterruptedException {
        // Create a token that expires in 1ms
        String token = Jwts.builder()
            .subject("user1")
            .expiration(new Date(System.currentTimeMillis() + 1))
            .signWith(jwtService.getPrivateKey(), Jwts.SIG.RS256)
            .compact();

        Thread.sleep(10);
        assertThrows(RuntimeException.class, () -> jwtService.verifyToken(token));
    }

    @Test
    void rejectsWrongAlgorithm() {
        // Sign with HS256 but service expects RS256
        SecretKey hmacKey = Keys.secretKeyFor(SignatureAlgorithm.HS256);
        String token = Jwts.builder().subject("user1").signWith(hmacKey).compact();
        assertThrows(RuntimeException.class, () -> jwtService.verifyToken(token));
    }

    @Test
    void rejectsTamperedSignature() {
        String token = jwtService.generateAccessToken("user1", "test@test.com", "user");
        assertThrows(RuntimeException.class,
            () -> jwtService.verifyToken(token + "tampered"));
    }

    @Test
    void refreshTokenRotationRevokesOldToken() throws Exception {
        String oldToken = jwtService.generateRefreshToken("user1");
        Claims oldClaims = jwtService.verifyToken(oldToken);

        // Perform rotation
        tokenRefreshService.refreshTokens(oldToken);

        // Old token JTI should be revoked
        assertTrue(revocationService.isRevoked(oldClaims.getId()));
    }
}
import pytest
import time

def test_rejects_expired_token():
    from datetime import timedelta, timezone
    payload = {
        "sub": "user1",
        "exp": datetime.now(timezone.utc) - timedelta(seconds=1),
    }
    token = jwt.encode(payload, PRIVATE_KEY_PEM, algorithm="RS256")
    with pytest.raises(ValueError, match="Token expired"):
        verify_token(token)

def test_rejects_wrong_algorithm():
    import secrets
    hmac_secret = secrets.token_hex(32)
    token = jwt.encode({"sub": "user1"}, hmac_secret, algorithm="HS256")
    with pytest.raises(ValueError, match="Invalid token"):
        verify_token(token)

def test_rejects_tampered_signature():
    token = generate_access_token("user1", "test@test.com", "user")
    with pytest.raises(ValueError, match="Invalid token"):
        verify_token(token + "tampered")

@pytest.mark.asyncio
async def test_refresh_rotation_revokes_old_token():
    old_token = generate_refresh_token("user1")
    old_jti   = jwt.decode(old_token, options={"verify_signature": False})["jti"]

    await refresh_tokens(RefreshTokenRequest(refresh_token=old_token))

    assert await is_token_revoked(old_jti)
using Xunit;
using System.IdentityModel.Tokens.Jwt;

public class JwtTokenServiceTests
{
    private readonly JwtTokenService        _jwtService;
    private readonly TokenRevocationService _revocation;
    private readonly TokenRefreshController _refreshController;

    public JwtTokenServiceTests()
    {
        _jwtService = new JwtTokenService();
        // Initialize other services...
    }

    [Fact]
    public void RejectsExpiredToken()
    {
        var handler = new JwtSecurityTokenHandler();
        var descriptor = new SecurityTokenDescriptor
        {
            Subject  = new ClaimsIdentity(new[] { new Claim("sub", "user1") }),
            Expires  = DateTime.UtcNow.AddSeconds(-1), // Already expired
            SigningCredentials = new SigningCredentials(
                _jwtService.PrivateKey, SecurityAlgorithms.RsaSha256),
        };
        var token = handler.WriteToken(handler.CreateToken(descriptor));

        Assert.Throws<Exception>(() => _jwtService.VerifyToken(token));
    }

    [Fact]
    public void RejectsTamperedSignature()
    {
        var token = _jwtService.GenerateAccessToken("user1", "test@test.com", "user");
        Assert.Throws<Exception>(() => _jwtService.VerifyToken(token + "tampered"));
    }

    [Fact]
    public async Task RefreshRotationRevokesOldToken()
    {
        var oldToken  = _jwtService.GenerateRefreshToken("user1");
        var principal = _jwtService.VerifyToken(oldToken);
        var oldJti    = principal.FindFirstValue(JwtRegisteredClaimNames.Jti)!;

        await _refreshController.RefreshTokens(
            new RefreshTokenRequest { RefreshToken = oldToken });

        Assert.True(await _revocation.IsRevokedAsync(oldJti));
    }
}

Conclusion

JWT authentication, when implemented correctly, provides a secure, scalable foundation for modern APIs. The key is understanding the trade-offs: statelessness simplifies distribution but complicates revocation. Short-lived access tokens with refresh rotation strikes the right balance for most applications.

Remember: JWTs are verified, not encrypted. Anyone can read the payload. Always treat tokens as public data and store sensitive information on the server.

Start with the production checklist, test edge cases thoroughly, and consider adding monitoring for token refresh patterns—suspicious spikes can indicate compromised refresh tokens.

Comments powered by Giscus are not yet configured. Set PUBLIC_GISCUS_REPO_ID and PUBLIC_GISCUS_CATEGORY_ID in apps/web/.env to enable.

PV

Written by Palakorn Voramongkol

Software Engineer Specialist with 20+ years of experience. Writing about architecture, performance, and building production systems.

More about me

Continue Reading