Back to Blog

Backend Session & Token Internals: How Authentication State Really Works

March 22, 20266 min read0 views

Backend Session & Token Internals: How Authentication State Really Works

Introduction

Every authenticated request your backend processes carries some proof of identity — a session cookie, a JWT, an opaque token. But what actually happens between the moment a user logs in and the moment your middleware says "yes, this request is authorized"? The answer involves cryptographic signing, distributed session stores, token rotation strategies, and subtle timing bugs that can open security holes.

This post builds every component from scratch: session stores, JWT creation and verification, refresh token rotation, token revocation, CSRF protection, and sliding expiration. You'll understand the exact bytes flowing through your auth pipeline.


Architecture Overview

┌─────────────────────────────────────────────────────────┐
│                     Client                               │
│  ┌─────────────┐  ┌──────────────┐  ┌───────────────┐  │
│  │ Cookie Jar   │  │ Token Store  │  │ CSRF Token    │  │
│  │ (httpOnly)   │  │ (memory/LS)  │  │ (meta tag)    │  │
│  └──────┬───────┘  └──────┬───────┘  └───────┬───────┘  │
└─────────┼─────────────────┼──────────────────┼──────────┘
          │                 │                  │
          ▼                 ▼                  ▼
┌─────────────────────────────────────────────────────────┐
│                   API Gateway / LB                        │
│  ┌──────────────────────────────────────────────────┐   │
│  │           TLS Termination Layer                    │   │
│  └──────────────────────┬───────────────────────────┘   │
└─────────────────────────┼───────────────────────────────┘
                          │
                          ▼
┌─────────────────────────────────────────────────────────┐
│              Authentication Middleware                     │
│                                                           │
│  ┌─────────┐  ┌──────────┐  ┌──────────┐  ┌──────────┐ │
│  │ Cookie   │  │ JWT      │  │ Session  │  │ CSRF     │ │
│  │ Parser   │──│ Verifier │──│ Resolver │──│ Checker  │ │
│  └─────────┘  └──────────┘  └──────────┘  └──────────┘ │
│                                    │                      │
│                     ┌──────────────┼──────────────┐      │
│                     ▼              ▼              ▼      │
│              ┌──────────┐  ┌──────────┐  ┌──────────┐  │
│              │  Redis    │  │ Database │  │ In-Memory│  │
│              │  Store    │  │  Store   │  │  Cache   │  │
│              └──────────┘  └──────────┘  └──────────┘  │
└─────────────────────────────────────────────────────────┘

Part 1: Session Store From Scratch

Core Session Interface

interface Session {
  id: string;
  userId: string;
  data: Record<string, unknown>;
  createdAt: number;
  lastAccessedAt: number;
  expiresAt: number;
  ipAddress: string;
  userAgent: string;
  rotationCount: number;
}

interface SessionStore {
  create(session: Session): Promise<void>;
  read(sessionId: string): Promise<Session | null>;
  update(sessionId: string, data: Partial<Session>): Promise<void>;
  destroy(sessionId: string): Promise<void>;
  destroyAllForUser(userId: string): Promise<number>;
  touch(sessionId: string): Promise<void>;
}

// Cryptographically secure session ID generator
class SessionIdGenerator {
  private readonly ID_LENGTH = 32; // 256 bits of entropy

  generate(): string {
    const bytes = new Uint8Array(this.ID_LENGTH);
    crypto.getRandomValues(bytes);
    return this.toBase64Url(bytes);
  }

  private toBase64Url(bytes: Uint8Array): string {
    const base64 = btoa(String.fromCharCode(...bytes));
    return base64
      .replace(/\+/g, '-')
      .replace(/\//g, '_')
      .replace(/=+$/, '');
  }

  // Timing-safe comparison to prevent timing attacks
  compare(a: string, b: string): boolean {
    if (a.length !== b.length) return false;

    const bufA = new TextEncoder().encode(a);
    const bufB = new TextEncoder().encode(b);

    let result = 0;
    for (let i = 0; i < bufA.length; i++) {
      result |= bufA[i] ^ bufB[i];
    }
    return result === 0;
  }
}

Redis-Backed Session Store

interface RedisClient {
  get(key: string): Promise<string | null>;
  set(key: string, value: string, options?: { EX?: number }): Promise<void>;
  del(key: string): Promise<number>;
  scan(cursor: number, pattern: string): Promise<[number, string[]]>;
  expire(key: string, seconds: number): Promise<void>;
  sadd(key: string, ...members: string[]): Promise<number>;
  smembers(key: string): Promise<string[]>;
  srem(key: string, ...members: string[]): Promise<number>;
}

class RedisSessionStore implements SessionStore {
  private readonly PREFIX = 'sess:';
  private readonly USER_PREFIX = 'user_sess:';
  private readonly defaultTTL: number;

  constructor(
    private readonly redis: RedisClient,
    private readonly options: {
      ttlSeconds: number;
      slidingExpiration: boolean;
      maxSessionsPerUser: number;
    }
  ) {
    this.defaultTTL = options.ttlSeconds;
  }

  async create(session: Session): Promise<void> {
    const key = this.PREFIX + session.id;
    const userKey = this.USER_PREFIX + session.userId;

    // Enforce max sessions per user
    await this.enforceSessionLimit(session.userId);

    // Store session data
    await this.redis.set(key, JSON.stringify(session), {
      EX: this.defaultTTL,
    });

    // Track session in user's session set
    await this.redis.sadd(userKey, session.id);
    await this.redis.expire(userKey, this.defaultTTL * 2);
  }

  async read(sessionId: string): Promise<Session | null> {
    const key = this.PREFIX + sessionId;
    const data = await this.redis.get(key);

    if (!data) return null;

    const session: Session = JSON.parse(data);

    // Check if expired (belt-and-suspenders with Redis TTL)
    if (session.expiresAt < Date.now()) {
      await this.destroy(sessionId);
      return null;
    }

    // Sliding expiration: reset TTL on each access
    if (this.options.slidingExpiration) {
      await this.touch(sessionId);
    }

    return session;
  }

  async update(sessionId: string, data: Partial<Session>): Promise<void> {
    const existing = await this.read(sessionId);
    if (!existing) {
      throw new Error(`Session ${sessionId} not found`);
    }

    const updated: Session = {
      ...existing,
      ...data,
      lastAccessedAt: Date.now(),
    };

    const key = this.PREFIX + sessionId;
    const remainingTTL = Math.max(
      0,
      Math.floor((updated.expiresAt - Date.now()) / 1000)
    );

    await this.redis.set(key, JSON.stringify(updated), {
      EX: remainingTTL || this.defaultTTL,
    });
  }

  async destroy(sessionId: string): Promise<void> {
    const session = await this.readRaw(sessionId);
    const key = this.PREFIX + sessionId;

    await this.redis.del(key);

    if (session) {
      const userKey = this.USER_PREFIX + session.userId;
      await this.redis.srem(userKey, sessionId);
    }
  }

  async destroyAllForUser(userId: string): Promise<number> {
    const userKey = this.USER_PREFIX + userId;
    const sessionIds = await this.redis.smembers(userKey);

    let count = 0;
    for (const sessionId of sessionIds) {
      await this.redis.del(this.PREFIX + sessionId);
      count++;
    }

    await this.redis.del(userKey);
    return count;
  }

  async touch(sessionId: string): Promise<void> {
    const key = this.PREFIX + sessionId;
    const data = await this.redis.get(key);

    if (!data) return;

    const session: Session = JSON.parse(data);
    session.lastAccessedAt = Date.now();
    session.expiresAt = Date.now() + this.defaultTTL * 1000;

    await this.redis.set(key, JSON.stringify(session), {
      EX: this.defaultTTL,
    });
  }

  private async readRaw(sessionId: string): Promise<Session | null> {
    const key = this.PREFIX + sessionId;
    const data = await this.redis.get(key);
    return data ? JSON.parse(data) : null;
  }

  private async enforceSessionLimit(userId: string): Promise<void> {
    const userKey = this.USER_PREFIX + userId;
    const sessionIds = await this.redis.smembers(userKey);

    if (sessionIds.length >= this.options.maxSessionsPerUser) {
      // Find and destroy the oldest session
      let oldest: Session | null = null;
      let oldestId: string | null = null;

      for (const id of sessionIds) {
        const session = await this.readRaw(id);
        if (!session) {
          // Clean up stale reference
          await this.redis.srem(userKey, id);
          continue;
        }
        if (!oldest || session.createdAt < oldest.createdAt) {
          oldest = session;
          oldestId = id;
        }
      }

      if (oldestId) {
        await this.destroy(oldestId);
      }
    }
  }
}

Session Rotation (Fixation Prevention)

class SessionRotator {
  constructor(
    private readonly store: SessionStore,
    private readonly idGenerator: SessionIdGenerator
  ) {}

  /**
   * Rotate session ID while preserving data.
   * Critical after authentication to prevent session fixation.
   *
   * Timeline of a session fixation attack:
   * 1. Attacker creates session: GET /login → sess_id=ATTACKER_SESSION
   * 2. Attacker tricks victim into using that session ID
   * 3. Victim authenticates with sess_id=ATTACKER_SESSION
   * 4. Without rotation: attacker now has an authenticated session
   * 5. With rotation: old ID invalidated, victim gets NEW_SESSION
   */
  async rotate(oldSessionId: string): Promise<string> {
    const session = await this.store.read(oldSessionId);
    if (!session) {
      throw new Error('Session not found for rotation');
    }

    const newSessionId = this.idGenerator.generate();

    // Create new session with same data but new ID
    const newSession: Session = {
      ...session,
      id: newSessionId,
      lastAccessedAt: Date.now(),
      rotationCount: session.rotationCount + 1,
    };

    // CRITICAL: Create new before destroying old
    // This prevents a window where no valid session exists
    await this.store.create(newSession);
    await this.store.destroy(oldSessionId);

    return newSessionId;
  }

  /**
   * Periodic rotation to limit session lifetime
   * even with sliding expiration enabled
   */
  async rotateIfNeeded(
    sessionId: string,
    maxAge: number
  ): Promise<string | null> {
    const session = await this.store.read(sessionId);
    if (!session) return null;

    const sessionAge = Date.now() - session.createdAt;
    if (sessionAge > maxAge) {
      return this.rotate(sessionId);
    }

    return null; // No rotation needed
  }
}

Part 2: JWT Internals From Scratch

JWT Structure

┌──────────────────────────────────────────────┐
│                  JWT Token                     │
│                                                │
│  Header          Payload          Signature    │
│  ┌──────┐       ┌──────────┐    ┌──────────┐ │
│  │ alg  │       │ sub      │    │ HMAC-    │ │
│  │ typ  │   .   │ iat      │  . │ SHA256(  │ │
│  │ kid  │       │ exp      │    │  header. │ │
│  └──┬───┘       │ iss      │    │  payload,│ │
│     │           │ aud      │    │  secret  │ │
│     │           │ custom   │    │ )        │ │
│     │           └────┬─────┘    └────┬─────┘ │
│     │                │               │        │
│     ▼                ▼               ▼        │
│  base64url(      base64url(      base64url(   │
│    JSON)           JSON)           bytes)      │
│     │                │               │        │
│     └────────.───────┴───────.───────┘        │
│            Final Token String                  │
└──────────────────────────────────────────────┘

HMAC-SHA256 JWT Implementation

interface JWTHeader {
  alg: 'HS256' | 'RS256' | 'ES256';
  typ: 'JWT';
  kid?: string;
}

interface JWTPayload {
  sub: string;        // Subject (user ID)
  iat: number;        // Issued at (unix timestamp)
  exp: number;        // Expiration (unix timestamp)
  nbf?: number;       // Not before
  iss?: string;       // Issuer
  aud?: string;       // Audience
  jti?: string;       // JWT ID (unique identifier)
  [key: string]: unknown;
}

class JWTEngine {
  private readonly encoder = new TextEncoder();
  private readonly decoder = new TextDecoder();

  // ─── Base64url encoding/decoding ────────────────────────

  private base64urlEncode(data: Uint8Array | string): string {
    const bytes =
      typeof data === 'string' ? this.encoder.encode(data) : data;
    const base64 = btoa(String.fromCharCode(...bytes));
    return base64
      .replace(/\+/g, '-')
      .replace(/\//g, '_')
      .replace(/=+$/, '');
  }

  private base64urlDecode(str: string): Uint8Array {
    // Restore padding
    const padded = str + '='.repeat((4 - (str.length % 4)) % 4);
    const base64 = padded.replace(/-/g, '+').replace(/_/g, '/');
    const binary = atob(base64);
    return new Uint8Array([...binary].map(c => c.charCodeAt(0)));
  }

  // ─── HMAC-SHA256 signing ────────────────────────────────

  async sign(
    payload: JWTPayload,
    secret: string,
    options?: { kid?: string }
  ): Promise<string> {
    const header: JWTHeader = {
      alg: 'HS256',
      typ: 'JWT',
      ...(options?.kid && { kid: options.kid }),
    };

    // Encode header and payload
    const headerB64 = this.base64urlEncode(JSON.stringify(header));
    const payloadB64 = this.base64urlEncode(JSON.stringify(payload));
    const signingInput = `${headerB64}.${payloadB64}`;

    // Create HMAC-SHA256 signature
    const key = await crypto.subtle.importKey(
      'raw',
      this.encoder.encode(secret),
      { name: 'HMAC', hash: 'SHA-256' },
      false,
      ['sign']
    );

    const signature = await crypto.subtle.sign(
      'HMAC',
      key,
      this.encoder.encode(signingInput)
    );

    const signatureB64 = this.base64urlEncode(new Uint8Array(signature));

    return `${signingInput}.${signatureB64}`;
  }

  // ─── Verification ──────────────────────────────────────

  async verify(
    token: string,
    secret: string,
    options?: {
      issuer?: string;
      audience?: string;
      clockTolerance?: number;
    }
  ): Promise<JWTPayload> {
    const parts = token.split('.');
    if (parts.length !== 3) {
      throw new JWTError('MALFORMED', 'Token must have 3 parts');
    }

    const [headerB64, payloadB64, signatureB64] = parts;

    // Step 1: Decode and validate header
    const header: JWTHeader = JSON.parse(
      this.decoder.decode(this.base64urlDecode(headerB64))
    );

    if (header.alg !== 'HS256') {
      throw new JWTError(
        'ALGORITHM_MISMATCH',
        `Expected HS256, got ${header.alg}`
      );
    }

    // Step 2: Verify signature (timing-safe)
    const signingInput = `${headerB64}.${payloadB64}`;
    const key = await crypto.subtle.importKey(
      'raw',
      this.encoder.encode(secret),
      { name: 'HMAC', hash: 'SHA-256' },
      false,
      ['verify']
    );

    const signatureBytes = this.base64urlDecode(signatureB64);
    const isValid = await crypto.subtle.verify(
      'HMAC',
      key,
      signatureBytes,
      this.encoder.encode(signingInput)
    );

    if (!isValid) {
      throw new JWTError('INVALID_SIGNATURE', 'Signature verification failed');
    }

    // Step 3: Decode and validate payload
    const payload: JWTPayload = JSON.parse(
      this.decoder.decode(this.base64urlDecode(payloadB64))
    );

    const now = Math.floor(Date.now() / 1000);
    const tolerance = options?.clockTolerance ?? 0;

    if (payload.exp && payload.exp + tolerance < now) {
      throw new JWTError(
        'TOKEN_EXPIRED',
        `Token expired at ${new Date(payload.exp * 1000).toISOString()}`
      );
    }

    if (payload.nbf && payload.nbf - tolerance > now) {
      throw new JWTError(
        'TOKEN_NOT_ACTIVE',
        `Token not valid before ${new Date(payload.nbf * 1000).toISOString()}`
      );
    }

    if (options?.issuer && payload.iss !== options.issuer) {
      throw new JWTError(
        'ISSUER_MISMATCH',
        `Expected issuer ${options.issuer}, got ${payload.iss}`
      );
    }

    if (options?.audience && payload.aud !== options.audience) {
      throw new JWTError(
        'AUDIENCE_MISMATCH',
        `Expected audience ${options.audience}, got ${payload.aud}`
      );
    }

    return payload;
  }

  // ─── Decode without verification (for debugging) ───────

  decode(token: string): { header: JWTHeader; payload: JWTPayload } {
    const parts = token.split('.');
    if (parts.length !== 3) {
      throw new JWTError('MALFORMED', 'Token must have 3 parts');
    }

    return {
      header: JSON.parse(
        this.decoder.decode(this.base64urlDecode(parts[0]))
      ),
      payload: JSON.parse(
        this.decoder.decode(this.base64urlDecode(parts[1]))
      ),
    };
  }
}

type JWTErrorCode =
  | 'MALFORMED'
  | 'ALGORITHM_MISMATCH'
  | 'INVALID_SIGNATURE'
  | 'TOKEN_EXPIRED'
  | 'TOKEN_NOT_ACTIVE'
  | 'ISSUER_MISMATCH'
  | 'AUDIENCE_MISMATCH';

class JWTError extends Error {
  constructor(
    public readonly code: JWTErrorCode,
    message: string
  ) {
    super(message);
    this.name = 'JWTError';
  }
}

Part 3: Refresh Token Rotation

┌─────────────────────────────────────────────────────────────┐
│                Refresh Token Rotation Flow                     │
│                                                               │
│  Login:                                                       │
│  ┌──────┐  credentials   ┌──────┐  access_token (15min)     │
│  │Client│ ──────────────▶│Server│ ──────────────────────▶   │
│  │      │                │      │  refresh_token_v1 (7d)     │
│  └──────┘                └──────┘                             │
│                                                               │
│  Access token expired:                                        │
│  ┌──────┐ refresh_token_v1 ┌──────┐  new access_token       │
│  │Client│ ────────────────▶│Server│ ────────────────────▶   │
│  │      │                  │      │  refresh_token_v2        │
│  └──────┘                  └──────┘  (v1 is INVALIDATED)     │
│                                                               │
│  Reuse detection (attacker replays v1):                       │
│  ┌────────┐ refresh_token_v1 ┌──────┐                        │
│  │Attacker│ ────────────────▶│Server│  ALREADY USED!         │
│  │        │                  │      │  → Revoke ALL tokens   │
│  └────────┘                  └──────┘    for this family      │
└─────────────────────────────────────────────────────────────┘
interface RefreshToken {
  id: string;
  userId: string;
  familyId: string;       // Groups tokens in a rotation chain
  parentId: string | null; // Previous token in rotation chain
  expiresAt: number;
  createdAt: number;
  revokedAt: number | null;
  revokeReason: string | null;
  ipAddress: string;
  userAgent: string;
}

interface TokenPair {
  accessToken: string;
  refreshToken: string;
  expiresIn: number;
}

class RefreshTokenManager {
  private readonly ACCESS_TOKEN_TTL = 15 * 60;      // 15 minutes
  private readonly REFRESH_TOKEN_TTL = 7 * 24 * 3600; // 7 days

  constructor(
    private readonly jwt: JWTEngine,
    private readonly store: RefreshTokenStore,
    private readonly secret: string
  ) {}

  /**
   * Issue initial token pair after successful authentication
   */
  async issueTokenPair(
    userId: string,
    metadata: { ipAddress: string; userAgent: string }
  ): Promise<TokenPair> {
    const familyId = crypto.randomUUID();
    return this.createTokenPair(userId, familyId, null, metadata);
  }

  /**
   * Rotate: exchange refresh token for new token pair
   */
  async rotate(
    refreshTokenId: string,
    metadata: { ipAddress: string; userAgent: string }
  ): Promise<TokenPair> {
    const token = await this.store.findById(refreshTokenId);

    if (!token) {
      throw new AuthError('INVALID_TOKEN', 'Refresh token not found');
    }

    // Check if already revoked — REUSE DETECTION
    if (token.revokedAt !== null) {
      // This token was already used! Possible token theft.
      // Revoke the ENTIRE family as a security measure.
      console.error(
        `[SECURITY] Refresh token reuse detected for family ${token.familyId}. ` +
        `User: ${token.userId}, IP: ${metadata.ipAddress}`
      );

      await this.store.revokeFamily(token.familyId, 'reuse_detected');

      throw new AuthError(
        'TOKEN_REUSE',
        'Refresh token reuse detected. All sessions in this family revoked.'
      );
    }

    // Check expiration
    if (token.expiresAt < Date.now()) {
      await this.store.revoke(token.id, 'expired');
      throw new AuthError('TOKEN_EXPIRED', 'Refresh token has expired');
    }

    // Revoke the current token (it's been used)
    await this.store.revoke(token.id, 'rotated');

    // Issue new pair in the same family
    return this.createTokenPair(
      token.userId,
      token.familyId,
      token.id,
      metadata
    );
  }

  /**
   * Revoke all tokens for a user (e.g., on password change)
   */
  async revokeAllForUser(userId: string, reason: string): Promise<number> {
    return this.store.revokeAllForUser(userId, reason);
  }

  private async createTokenPair(
    userId: string,
    familyId: string,
    parentId: string | null,
    metadata: { ipAddress: string; userAgent: string }
  ): Promise<TokenPair> {
    const now = Math.floor(Date.now() / 1000);

    // Create access token (JWT — stateless)
    const accessToken = await this.jwt.sign(
      {
        sub: userId,
        iat: now,
        exp: now + this.ACCESS_TOKEN_TTL,
        iss: 'auth-service',
        aud: 'api',
        type: 'access',
      },
      this.secret
    );

    // Create refresh token (opaque — stored in DB)
    const refreshToken: RefreshToken = {
      id: crypto.randomUUID(),
      userId,
      familyId,
      parentId,
      expiresAt: Date.now() + this.REFRESH_TOKEN_TTL * 1000,
      createdAt: Date.now(),
      revokedAt: null,
      revokeReason: null,
      ipAddress: metadata.ipAddress,
      userAgent: metadata.userAgent,
    };

    await this.store.save(refreshToken);

    return {
      accessToken,
      refreshToken: refreshToken.id,
      expiresIn: this.ACCESS_TOKEN_TTL,
    };
  }
}

interface RefreshTokenStore {
  save(token: RefreshToken): Promise<void>;
  findById(id: string): Promise<RefreshToken | null>;
  revoke(id: string, reason: string): Promise<void>;
  revokeFamily(familyId: string, reason: string): Promise<void>;
  revokeAllForUser(userId: string, reason: string): Promise<number>;
}

class AuthError extends Error {
  constructor(
    public readonly code: string,
    message: string
  ) {
    super(message);
    this.name = 'AuthError';
  }
}

Part 4: Token Revocation & Blacklisting

/**
 * Challenge: JWTs are stateless. Once issued, they're valid until expiration.
 * How do you revoke them before expiry?
 *
 * Strategy 1: Blacklist — Store revoked token IDs (requires DB check each request)
 * Strategy 2: Short-lived + Refresh — 15min access tokens, revoke refresh tokens
 * Strategy 3: Versioning — Store a "token version" per user; reject older versions
 *
 * We implement all three and compose them.
 */

class TokenBlacklist {
  private readonly PREFIX = 'blacklist:';

  constructor(private readonly redis: RedisClient) {}

  /**
   * Blacklist a specific JWT by its jti claim.
   * TTL matches the token's remaining lifetime to auto-cleanup.
   */
  async add(jti: string, expiresAt: number): Promise<void> {
    const ttl = Math.max(0, Math.floor((expiresAt * 1000 - Date.now()) / 1000));
    if (ttl > 0) {
      await this.redis.set(this.PREFIX + jti, '1', { EX: ttl });
    }
  }

  async isBlacklisted(jti: string): Promise<boolean> {
    const result = await this.redis.get(this.PREFIX + jti);
    return result !== null;
  }
}

class TokenVersionStore {
  private readonly PREFIX = 'token_ver:';

  constructor(private readonly redis: RedisClient) {}

  async getCurrentVersion(userId: string): Promise<number> {
    const ver = await this.redis.get(this.PREFIX + userId);
    return ver ? parseInt(ver, 10) : 0;
  }

  /**
   * Increment version — all tokens issued with older version
   * are immediately considered invalid.
   */
  async incrementVersion(userId: string): Promise<number> {
    // Using Redis INCR would be ideal; simulating here
    const current = await this.getCurrentVersion(userId);
    const next = current + 1;
    await this.redis.set(this.PREFIX + userId, String(next));
    return next;
  }

  async isVersionValid(userId: string, tokenVersion: number): Promise<boolean> {
    const current = await this.getCurrentVersion(userId);
    return tokenVersion >= current;
  }
}

/**
 * Composite verifier: checks blacklist, version, and JWT validity
 */
class CompositeTokenVerifier {
  constructor(
    private readonly jwt: JWTEngine,
    private readonly blacklist: TokenBlacklist,
    private readonly versionStore: TokenVersionStore,
    private readonly secret: string
  ) {}

  async verify(token: string): Promise<JWTPayload> {
    // Step 1: Verify JWT signature and claims
    const payload = await this.jwt.verify(token, this.secret, {
      issuer: 'auth-service',
      audience: 'api',
      clockTolerance: 5,
    });

    // Step 2: Check blacklist (if jti present)
    if (payload.jti) {
      const isBlacklisted = await this.blacklist.isBlacklisted(payload.jti);
      if (isBlacklisted) {
        throw new AuthError('TOKEN_REVOKED', 'Token has been revoked');
      }
    }

    // Step 3: Check token version
    if (typeof payload.ver === 'number') {
      const isValid = await this.versionStore.isVersionValid(
        payload.sub,
        payload.ver as number
      );
      if (!isValid) {
        throw new AuthError(
          'TOKEN_VERSION_OUTDATED',
          'Token version is outdated'
        );
      }
    }

    return payload;
  }
}

Part 5: CSRF Protection Internals

┌─────────────────────────────────────────────────────────────────┐
│                  Double Submit Cookie Pattern                      │
│                                                                   │
│  Step 1: Server sets CSRF token as cookie AND in response body   │
│                                                                   │
│  Response:                                                        │
│  ┌──────────────────────────────────────────────────────┐        │
│  │ Set-Cookie: csrf_token=abc123; SameSite=Strict       │        │
│  │ Body: { csrfToken: "abc123" }                        │        │
│  └──────────────────────────────────────────────────────┘        │
│                                                                   │
│  Step 2: Client sends BOTH cookie and header on mutations         │
│                                                                   │
│  Request:                                                         │
│  ┌──────────────────────────────────────────────────────┐        │
│  │ Cookie: csrf_token=abc123  (sent automatically)      │        │
│  │ X-CSRF-Token: abc123      (set by JavaScript)        │        │
│  └──────────────────────────────────────────────────────┘        │
│                                                                   │
│  Why it works:                                                    │
│  - Cross-origin requests CAN send cookies (if SameSite=None)    │
│  - Cross-origin requests CANNOT read cookies or set headers      │
│  - So attacker can't get the value to put in the header          │
└─────────────────────────────────────────────────────────────────┘
interface CSRFOptions {
  cookieName: string;
  headerName: string;
  tokenLength: number;
  sameSite: 'Strict' | 'Lax' | 'None';
  secure: boolean;
  signedTokens: boolean;
}

class CSRFProtection {
  private readonly defaults: CSRFOptions = {
    cookieName: '_csrf',
    headerName: 'x-csrf-token',
    tokenLength: 32,
    sameSite: 'Lax',
    secure: true,
    signedTokens: true,
  };

  private readonly options: CSRFOptions;

  constructor(
    private readonly secret: string,
    options?: Partial<CSRFOptions>
  ) {
    this.options = { ...this.defaults, ...options };
  }

  /**
   * Generate a CSRF token.
   * If signedTokens is enabled, token = random + HMAC(random, secret)
   * This means we don't need server-side storage.
   */
  async generateToken(): Promise<string> {
    const random = new Uint8Array(this.options.tokenLength);
    crypto.getRandomValues(random);
    const randomStr = this.toHex(random);

    if (!this.options.signedTokens) {
      return randomStr;
    }

    // Sign the random value so we can verify without storage
    const signature = await this.hmacSign(randomStr);
    return `${randomStr}.${signature}`;
  }

  /**
   * Verify: compare cookie token with header token
   */
  async verifyRequest(request: {
    cookies: Record<string, string>;
    headers: Record<string, string>;
    method: string;
  }): Promise<boolean> {
    // Safe methods don't need CSRF protection
    const safeMethods = ['GET', 'HEAD', 'OPTIONS'];
    if (safeMethods.includes(request.method.toUpperCase())) {
      return true;
    }

    const cookieToken = request.cookies[this.options.cookieName];
    const headerToken = request.headers[this.options.headerName.toLowerCase()];

    if (!cookieToken || !headerToken) {
      return false;
    }

    // Tokens must match
    if (!this.timingSafeEqual(cookieToken, headerToken)) {
      return false;
    }

    // If signed, verify the signature
    if (this.options.signedTokens) {
      return this.verifySignedToken(cookieToken);
    }

    return true;
  }

  private async verifySignedToken(token: string): Promise<boolean> {
    const parts = token.split('.');
    if (parts.length !== 2) return false;

    const [random, signature] = parts;
    const expectedSignature = await this.hmacSign(random);

    return this.timingSafeEqual(signature, expectedSignature);
  }

  private async hmacSign(data: string): Promise<string> {
    const key = await crypto.subtle.importKey(
      'raw',
      new TextEncoder().encode(this.secret),
      { name: 'HMAC', hash: 'SHA-256' },
      false,
      ['sign']
    );

    const signature = await crypto.subtle.sign(
      'HMAC',
      key,
      new TextEncoder().encode(data)
    );

    return this.toHex(new Uint8Array(signature));
  }

  private timingSafeEqual(a: string, b: string): boolean {
    if (a.length !== b.length) return false;
    let result = 0;
    for (let i = 0; i < a.length; i++) {
      result |= a.charCodeAt(i) ^ b.charCodeAt(i);
    }
    return result === 0;
  }

  private toHex(bytes: Uint8Array): string {
    return Array.from(bytes)
      .map(b => b.toString(16).padStart(2, '0'))
      .join('');
  }

  /**
   * Build Set-Cookie header for CSRF token
   */
  buildCookieHeader(token: string): string {
    const parts = [
      `${this.options.cookieName}=${token}`,
      'Path=/',
      `SameSite=${this.options.sameSite}`,
    ];

    if (this.options.secure) parts.push('Secure');
    // Note: NOT httpOnly — JavaScript needs to read it
    // to send in the custom header

    return parts.join('; ');
  }
}

interface CookieOptions {
  name: string;
  value: string;
  maxAge?: number;        // seconds
  expires?: Date;
  path?: string;
  domain?: string;
  secure?: boolean;
  httpOnly?: boolean;
  sameSite?: 'Strict' | 'Lax' | 'None';
  partitioned?: boolean;  // CHIPS (Cookies Having Independent Partitioned State)
  priority?: 'Low' | 'Medium' | 'High';
}

class CookieEngine {
  /**
   * Sign a cookie value to detect tampering.
   * Signed value = value.HMAC(value, secret)
   */
  async signCookie(value: string, secret: string): Promise<string> {
    const key = await crypto.subtle.importKey(
      'raw',
      new TextEncoder().encode(secret),
      { name: 'HMAC', hash: 'SHA-256' },
      false,
      ['sign']
    );

    const sig = await crypto.subtle.sign(
      'HMAC',
      key,
      new TextEncoder().encode(value)
    );

    const sigStr = btoa(String.fromCharCode(...new Uint8Array(sig)))
      .replace(/\+/g, '-')
      .replace(/\//g, '_')
      .replace(/=+$/, '');

    return `s:${value}.${sigStr}`;
  }

  async unsignCookie(
    signed: string,
    secret: string
  ): Promise<string | null> {
    if (!signed.startsWith('s:')) return null;

    const withoutPrefix = signed.slice(2);
    const dotIndex = withoutPrefix.lastIndexOf('.');
    if (dotIndex === -1) return null;

    const value = withoutPrefix.slice(0, dotIndex);
    const expectedSigned = await this.signCookie(value, secret);

    // Timing-safe comparison
    if (signed.length !== expectedSigned.length) return null;
    let diff = 0;
    for (let i = 0; i < signed.length; i++) {
      diff |= signed.charCodeAt(i) ^ expectedSigned.charCodeAt(i);
    }

    return diff === 0 ? value : null;
  }

  /**
   * Encrypt cookie value for sensitive data.
   * Uses AES-256-GCM for authenticated encryption.
   */
  async encryptCookie(value: string, secret: string): Promise<string> {
    const keyMaterial = await crypto.subtle.importKey(
      'raw',
      new TextEncoder().encode(secret).slice(0, 32),
      'AES-GCM',
      false,
      ['encrypt']
    );

    const iv = crypto.getRandomValues(new Uint8Array(12)); // 96-bit nonce
    const encrypted = await crypto.subtle.encrypt(
      { name: 'AES-GCM', iv },
      keyMaterial,
      new TextEncoder().encode(value)
    );

    // Combine IV + ciphertext
    const combined = new Uint8Array(iv.length + encrypted.byteLength);
    combined.set(iv);
    combined.set(new Uint8Array(encrypted), iv.length);

    return btoa(String.fromCharCode(...combined))
      .replace(/\+/g, '-')
      .replace(/\//g, '_')
      .replace(/=+$/, '');
  }

  async decryptCookie(
    encrypted: string,
    secret: string
  ): Promise<string | null> {
    try {
      const padded = encrypted + '='.repeat((4 - (encrypted.length % 4)) % 4);
      const binary = atob(padded.replace(/-/g, '+').replace(/_/g, '/'));
      const bytes = new Uint8Array([...binary].map(c => c.charCodeAt(0)));

      const iv = bytes.slice(0, 12);
      const ciphertext = bytes.slice(12);

      const keyMaterial = await crypto.subtle.importKey(
        'raw',
        new TextEncoder().encode(secret).slice(0, 32),
        'AES-GCM',
        false,
        ['decrypt']
      );

      const decrypted = await crypto.subtle.decrypt(
        { name: 'AES-GCM', iv },
        keyMaterial,
        ciphertext
      );

      return new TextDecoder().decode(decrypted);
    } catch {
      return null; // Tampered or corrupted
    }
  }

  /**
   * Build a Set-Cookie header string with all security attributes
   */
  serialize(options: CookieOptions): string {
    const parts: string[] = [
      `${encodeURIComponent(options.name)}=${encodeURIComponent(options.value)}`,
    ];

    if (options.maxAge !== undefined) {
      parts.push(`Max-Age=${options.maxAge}`);
    }

    if (options.expires) {
      parts.push(`Expires=${options.expires.toUTCString()}`);
    }

    if (options.path) parts.push(`Path=${options.path}`);
    if (options.domain) parts.push(`Domain=${options.domain}`);
    if (options.secure) parts.push('Secure');
    if (options.httpOnly) parts.push('HttpOnly');

    if (options.sameSite) {
      parts.push(`SameSite=${options.sameSite}`);
      // SameSite=None requires Secure
      if (options.sameSite === 'None' && !options.secure) {
        throw new Error('SameSite=None requires Secure attribute');
      }
    }

    if (options.partitioned) parts.push('Partitioned');
    if (options.priority) parts.push(`Priority=${options.priority}`);

    return parts.join('; ');
  }

  /**
   * Parse Cookie header into key-value pairs
   */
  parse(cookieHeader: string): Record<string, string> {
    const cookies: Record<string, string> = {};

    cookieHeader.split(';').forEach(pair => {
      const [name, ...rest] = pair.trim().split('=');
      if (name) {
        cookies[decodeURIComponent(name.trim())] = decodeURIComponent(
          rest.join('=').trim()
        );
      }
    });

    return cookies;
  }
}

Part 7: Authentication Middleware Pipeline

type NextFunction = () => Promise<void>;

interface Request {
  method: string;
  path: string;
  headers: Record<string, string>;
  cookies: Record<string, string>;
  ip: string;
  user?: AuthenticatedUser;
  session?: Session;
}

interface Response {
  statusCode: number;
  headers: Record<string, string>;
  body: unknown;
  setHeader(name: string, value: string): void;
  setCookie(name: string, value: string, options: Partial<CookieOptions>): void;
  json(data: unknown, status?: number): void;
}

interface AuthenticatedUser {
  id: string;
  email: string;
  roles: string[];
  permissions: string[];
  sessionId?: string;
  tokenVersion?: number;
}

type Middleware = (req: Request, res: Response, next: NextFunction) => Promise<void>;

class AuthMiddlewarePipeline {
  private readonly middlewares: Middleware[] = [];

  use(middleware: Middleware): this {
    this.middlewares.push(middleware);
    return this;
  }

  async execute(req: Request, res: Response): Promise<void> {
    let index = 0;

    const next: NextFunction = async () => {
      if (index >= this.middlewares.length) return;
      const middleware = this.middlewares[index++];
      await middleware(req, res, next);
    };

    try {
      await next();
    } catch (error) {
      if (error instanceof AuthError) {
        res.json(
          { error: error.code, message: error.message },
          this.getStatusCode(error.code)
        );
      } else {
        res.json(
          { error: 'INTERNAL_ERROR', message: 'Authentication failed' },
          500
        );
      }
    }
  }

  private getStatusCode(code: string): number {
    switch (code) {
      case 'TOKEN_EXPIRED':
      case 'TOKEN_REVOKED':
      case 'INVALID_TOKEN':
      case 'TOKEN_REUSE':
        return 401;
      case 'INSUFFICIENT_PERMISSIONS':
        return 403;
      case 'RATE_LIMITED':
        return 429;
      default:
        return 401;
    }
  }
}

// ─── Concrete Middleware Implementations ──────────────────

function cookieParserMiddleware(cookieSecret: string): Middleware {
  const engine = new CookieEngine();

  return async (req, _res, next) => {
    const cookieHeader = req.headers['cookie'];
    if (cookieHeader) {
      req.cookies = engine.parse(cookieHeader);

      // Unsign signed cookies
      for (const [key, value] of Object.entries(req.cookies)) {
        if (value.startsWith('s:')) {
          const unsigned = await engine.unsignCookie(value, cookieSecret);
          if (unsigned !== null) {
            req.cookies[key] = unsigned;
          } else {
            delete req.cookies[key]; // Invalid signature
          }
        }
      }
    }
    await next();
  };
}

function jwtAuthMiddleware(verifier: CompositeTokenVerifier): Middleware {
  return async (req, _res, next) => {
    // Try Authorization header first, then cookie
    let token: string | null = null;

    const authHeader = req.headers['authorization'];
    if (authHeader?.startsWith('Bearer ')) {
      token = authHeader.slice(7);
    } else if (req.cookies['access_token']) {
      token = req.cookies['access_token'];
    }

    if (!token) {
      throw new AuthError('MISSING_TOKEN', 'No authentication token provided');
    }

    const payload = await verifier.verify(token);

    req.user = {
      id: payload.sub,
      email: payload.email as string,
      roles: (payload.roles as string[]) || [],
      permissions: (payload.permissions as string[]) || [],
      tokenVersion: payload.ver as number | undefined,
    };

    await next();
  };
}

function csrfMiddleware(csrf: CSRFProtection): Middleware {
  return async (req, _res, next) => {
    const isValid = await csrf.verifyRequest({
      cookies: req.cookies,
      headers: req.headers,
      method: req.method,
    });

    if (!isValid) {
      throw new AuthError('CSRF_FAILED', 'CSRF token validation failed');
    }

    await next();
  };
}

function rateLimitMiddleware(
  windowMs: number,
  maxRequests: number,
  redis: RedisClient
): Middleware {
  return async (req, res, next) => {
    const key = `ratelimit:auth:${req.ip}`;
    const current = await redis.get(key);
    const count = current ? parseInt(current, 10) : 0;

    if (count >= maxRequests) {
      res.setHeader('Retry-After', String(Math.ceil(windowMs / 1000)));
      throw new AuthError('RATE_LIMITED', 'Too many authentication attempts');
    }

    // Increment counter (simplified; production uses MULTI/EXEC)
    await redis.set(key, String(count + 1), {
      EX: Math.ceil(windowMs / 1000),
    });

    await next();
  };
}

function permissionMiddleware(...requiredPermissions: string[]): Middleware {
  return async (req, _res, next) => {
    if (!req.user) {
      throw new AuthError('UNAUTHENTICATED', 'User not authenticated');
    }

    const hasAll = requiredPermissions.every(perm =>
      req.user!.permissions.includes(perm)
    );

    if (!hasAll) {
      throw new AuthError(
        'INSUFFICIENT_PERMISSIONS',
        `Missing permissions: ${requiredPermissions
          .filter(p => !req.user!.permissions.includes(p))
          .join(', ')}`
      );
    }

    await next();
  };
}

// ─── Composing the full pipeline ─────────────────────────

function createAuthPipeline(deps: {
  cookieSecret: string;
  tokenVerifier: CompositeTokenVerifier;
  csrfProtection: CSRFProtection;
  redis: RedisClient;
}): AuthMiddlewarePipeline {
  const pipeline = new AuthMiddlewarePipeline();

  pipeline
    .use(rateLimitMiddleware(15 * 60 * 1000, 100, deps.redis))
    .use(cookieParserMiddleware(deps.cookieSecret))
    .use(jwtAuthMiddleware(deps.tokenVerifier))
    .use(csrfMiddleware(deps.csrfProtection));

  return pipeline;
}

Part 8: Key Rotation & Multi-Key Support

interface KeyEntry {
  kid: string;        // Key ID
  secret: string;     // The actual key material
  algorithm: string;
  createdAt: number;
  expiresAt: number;
  status: 'active' | 'rotated' | 'revoked';
}

class KeyRotationManager {
  private keys: Map<string, KeyEntry> = new Map();
  private activeKid: string | null = null;

  constructor(
    private readonly store: KeyStore,
    private readonly rotationIntervalMs: number = 30 * 24 * 3600 * 1000 // 30 days
  ) {}

  async initialize(): Promise<void> {
    const keys = await this.store.loadAll();
    for (const key of keys) {
      this.keys.set(key.kid, key);
      if (key.status === 'active') {
        this.activeKid = key.kid;
      }
    }

    if (!this.activeKid) {
      await this.generateNewKey();
    }
  }

  /**
   * Get the current active key for signing
   */
  getSigningKey(): KeyEntry {
    if (!this.activeKid) {
      throw new Error('No active signing key');
    }
    return this.keys.get(this.activeKid)!;
  }

  /**
   * Get key by ID for verification.
   * Allows verifying tokens signed with rotated (but not revoked) keys.
   */
  getVerificationKey(kid: string): KeyEntry | null {
    const key = this.keys.get(kid);
    if (!key) return null;
    if (key.status === 'revoked') return null;
    return key;
  }

  /**
   * Rotate: create new key, mark old as rotated.
   * Old key remains valid for verification until explicitly revoked.
   *
   * Timeline:
   *   t=0: Key A is active (signing + verification)
   *   t=1: Rotation → Key B active, Key A rotated (verification only)
   *   t=2: Key A revoked. Tokens signed with A are now invalid.
   */
  async rotate(): Promise<KeyEntry> {
    // Mark current active key as rotated
    if (this.activeKid) {
      const current = this.keys.get(this.activeKid)!;
      current.status = 'rotated';
      await this.store.update(current);
    }

    return this.generateNewKey();
  }

  async revokeKey(kid: string): Promise<void> {
    const key = this.keys.get(kid);
    if (!key) return;

    key.status = 'revoked';
    await this.store.update(key);
  }

  /**
   * Check if rotation is needed based on schedule
   */
  async rotateIfNeeded(): Promise<boolean> {
    if (!this.activeKid) return false;

    const active = this.keys.get(this.activeKid)!;
    const age = Date.now() - active.createdAt;

    if (age > this.rotationIntervalMs) {
      await this.rotate();
      return true;
    }

    return false;
  }

  /**
   * JWKS endpoint response (for public key distribution)
   */
  getJWKS(): { keys: Array<{ kid: string; alg: string; use: string }> } {
    const publicKeys = Array.from(this.keys.values())
      .filter(k => k.status !== 'revoked')
      .map(k => ({
        kid: k.kid,
        alg: k.algorithm,
        use: 'sig',
      }));

    return { keys: publicKeys };
  }

  private async generateNewKey(): Promise<KeyEntry> {
    const bytes = new Uint8Array(32);
    crypto.getRandomValues(bytes);

    const key: KeyEntry = {
      kid: crypto.randomUUID(),
      secret: btoa(String.fromCharCode(...bytes)),
      algorithm: 'HS256',
      createdAt: Date.now(),
      expiresAt: Date.now() + this.rotationIntervalMs * 2,
      status: 'active',
    };

    this.keys.set(key.kid, key);
    this.activeKid = key.kid;
    await this.store.save(key);

    return key;
  }
}

interface KeyStore {
  loadAll(): Promise<KeyEntry[]>;
  save(key: KeyEntry): Promise<void>;
  update(key: KeyEntry): Promise<void>;
}

Part 9: Session Fingerprinting & Anomaly Detection

interface SessionFingerprint {
  ipAddress: string;
  userAgent: string;
  acceptLanguage: string;
  timezone: string;
  screenResolution: string;
  hash: string;
}

class SessionFingerprintEngine {
  /**
   * Create a fingerprint from request metadata.
   * Used to detect session hijacking — if fingerprint changes
   * dramatically, the session may have been stolen.
   */
  async createFingerprint(metadata: {
    ipAddress: string;
    userAgent: string;
    acceptLanguage: string;
    timezone?: string;
    screenResolution?: string;
  }): Promise<SessionFingerprint> {
    const fp: SessionFingerprint = {
      ipAddress: metadata.ipAddress,
      userAgent: metadata.userAgent,
      acceptLanguage: metadata.acceptLanguage,
      timezone: metadata.timezone || '',
      screenResolution: metadata.screenResolution || '',
      hash: '',
    };

    // Hash the stable components (not IP, which changes with mobile)
    const stableData = [
      fp.userAgent,
      fp.acceptLanguage,
      fp.timezone,
      fp.screenResolution,
    ].join('|');

    const hashBuffer = await crypto.subtle.digest(
      'SHA-256',
      new TextEncoder().encode(stableData)
    );

    fp.hash = Array.from(new Uint8Array(hashBuffer))
      .map(b => b.toString(16).padStart(2, '0'))
      .join('');

    return fp;
  }

  /**
   * Compare fingerprints and return a similarity score (0 to 1).
   * Score below threshold triggers security response.
   */
  compareFingerpints(
    stored: SessionFingerprint,
    current: SessionFingerprint
  ): { score: number; changes: string[] } {
    const changes: string[] = [];
    let matchCount = 0;
    let totalWeight = 0;

    const checks: Array<{
      name: string;
      weight: number;
      match: boolean;
    }> = [
      {
        name: 'userAgent',
        weight: 3,
        match: stored.userAgent === current.userAgent,
      },
      {
        name: 'acceptLanguage',
        weight: 2,
        match: stored.acceptLanguage === current.acceptLanguage,
      },
      {
        name: 'timezone',
        weight: 2,
        match: stored.timezone === current.timezone,
      },
      {
        name: 'screenResolution',
        weight: 1,
        match: stored.screenResolution === current.screenResolution,
      },
      {
        name: 'ipSubnet',
        weight: 2,
        match: this.sameSubnet(stored.ipAddress, current.ipAddress),
      },
    ];

    for (const check of checks) {
      totalWeight += check.weight;
      if (check.match) {
        matchCount += check.weight;
      } else {
        changes.push(check.name);
      }
    }

    return {
      score: matchCount / totalWeight,
      changes,
    };
  }

  private sameSubnet(ip1: string, ip2: string): boolean {
    // Compare /24 subnet for IPv4
    const parts1 = ip1.split('.');
    const parts2 = ip2.split('.');

    if (parts1.length !== 4 || parts2.length !== 4) return false;

    return (
      parts1[0] === parts2[0] &&
      parts1[1] === parts2[1] &&
      parts1[2] === parts2[2]
    );
  }
}

class SessionSecurityMonitor {
  private readonly SIMILARITY_THRESHOLD = 0.6;
  private readonly MAX_FAILED_ATTEMPTS = 5;
  private readonly LOCKOUT_DURATION = 15 * 60 * 1000; // 15 minutes

  private failedAttempts: Map<string, { count: number; lastAttempt: number }> =
    new Map();

  constructor(
    private readonly fingerprintEngine: SessionFingerprintEngine,
    private readonly sessionStore: SessionStore,
    private readonly onSecurityEvent: (event: SecurityEvent) => void
  ) {}

  async validateRequest(
    sessionId: string,
    currentMetadata: {
      ipAddress: string;
      userAgent: string;
      acceptLanguage: string;
    }
  ): Promise<{ allowed: boolean; reason?: string }> {
    // Check lockout
    if (this.isLockedOut(currentMetadata.ipAddress)) {
      return { allowed: false, reason: 'Account temporarily locked' };
    }

    const session = await this.sessionStore.read(sessionId);
    if (!session) {
      return { allowed: false, reason: 'Session not found' };
    }

    // Compare fingerprints
    const storedFp: SessionFingerprint = session.data
      .fingerprint as SessionFingerprint;
    if (!storedFp) return { allowed: true }; // No fingerprint stored

    const currentFp = await this.fingerprintEngine.createFingerprint(
      currentMetadata
    );

    const comparison = this.fingerprintEngine.compareFingerpints(
      storedFp,
      currentFp
    );

    if (comparison.score < this.SIMILARITY_THRESHOLD) {
      this.onSecurityEvent({
        type: 'SESSION_ANOMALY',
        sessionId,
        userId: session.userId,
        score: comparison.score,
        changes: comparison.changes,
        ipAddress: currentMetadata.ipAddress,
        timestamp: Date.now(),
      });

      // Record failed attempt
      this.recordFailedAttempt(currentMetadata.ipAddress);

      return {
        allowed: false,
        reason: `Session fingerprint mismatch (score: ${comparison.score.toFixed(2)})`,
      };
    }

    return { allowed: true };
  }

  private recordFailedAttempt(ipAddress: string): void {
    const existing = this.failedAttempts.get(ipAddress);
    this.failedAttempts.set(ipAddress, {
      count: (existing?.count || 0) + 1,
      lastAttempt: Date.now(),
    });
  }

  private isLockedOut(ipAddress: string): boolean {
    const record = this.failedAttempts.get(ipAddress);
    if (!record) return false;

    if (record.count >= this.MAX_FAILED_ATTEMPTS) {
      const elapsed = Date.now() - record.lastAttempt;
      if (elapsed < this.LOCKOUT_DURATION) {
        return true;
      }
      // Lockout expired
      this.failedAttempts.delete(ipAddress);
    }

    return false;
  }
}

interface SecurityEvent {
  type: 'SESSION_ANOMALY' | 'TOKEN_REUSE' | 'BRUTE_FORCE';
  sessionId: string;
  userId: string;
  score?: number;
  changes?: string[];
  ipAddress: string;
  timestamp: number;
}

Part 10: Complete Auth Flow Orchestrator

class AuthOrchestrator {
  constructor(
    private readonly sessionStore: RedisSessionStore,
    private readonly sessionIdGen: SessionIdGenerator,
    private readonly sessionRotator: SessionRotator,
    private readonly jwt: JWTEngine,
    private readonly refreshManager: RefreshTokenManager,
    private readonly keyManager: KeyRotationManager,
    private readonly csrf: CSRFProtection,
    private readonly cookie: CookieEngine,
    private readonly fingerprint: SessionFingerprintEngine,
    private readonly cookieSecret: string
  ) {}

  /**
   * Full login flow
   */
  async login(
    userId: string,
    email: string,
    roles: string[],
    permissions: string[],
    metadata: {
      ipAddress: string;
      userAgent: string;
      acceptLanguage: string;
    }
  ): Promise<{
    cookies: string[];
    body: { csrfToken: string; expiresIn: number };
  }> {
    // 1. Create session
    const sessionId = this.sessionIdGen.generate();
    const fp = await this.fingerprint.createFingerprint(metadata);

    const session: Session = {
      id: sessionId,
      userId,
      data: {
        email,
        roles,
        permissions,
        fingerprint: fp,
      },
      createdAt: Date.now(),
      lastAccessedAt: Date.now(),
      expiresAt: Date.now() + 7 * 24 * 3600 * 1000, // 7 days
      ipAddress: metadata.ipAddress,
      userAgent: metadata.userAgent,
      rotationCount: 0,
    };

    await this.sessionStore.create(session);

    // 2. Issue token pair
    const tokenPair = await this.refreshManager.issueTokenPair(userId, {
      ipAddress: metadata.ipAddress,
      userAgent: metadata.userAgent,
    });

    // 3. Generate CSRF token
    const csrfToken = await this.csrf.generateToken();

    // 4. Build cookies
    const cookies = [
      // Access token: short-lived, httpOnly
      this.cookie.serialize({
        name: 'access_token',
        value: await this.cookie.signCookie(tokenPair.accessToken, this.cookieSecret),
        httpOnly: true,
        secure: true,
        sameSite: 'Lax',
        path: '/',
        maxAge: tokenPair.expiresIn,
      }),

      // Refresh token: longer-lived, httpOnly, restricted path
      this.cookie.serialize({
        name: 'refresh_token',
        value: await this.cookie.signCookie(tokenPair.refreshToken, this.cookieSecret),
        httpOnly: true,
        secure: true,
        sameSite: 'Strict',
        path: '/api/auth/refresh',
        maxAge: 7 * 24 * 3600,
      }),

      // Session ID: httpOnly
      this.cookie.serialize({
        name: 'session_id',
        value: await this.cookie.signCookie(sessionId, this.cookieSecret),
        httpOnly: true,
        secure: true,
        sameSite: 'Lax',
        path: '/',
        maxAge: 7 * 24 * 3600,
      }),

      // CSRF token: NOT httpOnly (JS needs to read it)
      this.csrf.buildCookieHeader(csrfToken),
    ];

    // 5. Rotate session ID post-authentication (fixation prevention)
    const newSessionId = await this.sessionRotator.rotate(sessionId);

    // Update session cookie with rotated ID
    cookies.push(
      this.cookie.serialize({
        name: 'session_id',
        value: await this.cookie.signCookie(newSessionId, this.cookieSecret),
        httpOnly: true,
        secure: true,
        sameSite: 'Lax',
        path: '/',
        maxAge: 7 * 24 * 3600,
      })
    );

    return {
      cookies,
      body: {
        csrfToken,
        expiresIn: tokenPair.expiresIn,
      },
    };
  }

  /**
   * Refresh token flow
   */
  async refresh(
    refreshTokenId: string,
    metadata: { ipAddress: string; userAgent: string }
  ): Promise<{
    cookies: string[];
    body: { expiresIn: number };
  }> {
    const tokenPair = await this.refreshManager.rotate(refreshTokenId, metadata);

    const cookies = [
      this.cookie.serialize({
        name: 'access_token',
        value: await this.cookie.signCookie(tokenPair.accessToken, this.cookieSecret),
        httpOnly: true,
        secure: true,
        sameSite: 'Lax',
        path: '/',
        maxAge: tokenPair.expiresIn,
      }),
      this.cookie.serialize({
        name: 'refresh_token',
        value: await this.cookie.signCookie(tokenPair.refreshToken, this.cookieSecret),
        httpOnly: true,
        secure: true,
        sameSite: 'Strict',
        path: '/api/auth/refresh',
        maxAge: 7 * 24 * 3600,
      }),
    ];

    return {
      cookies,
      body: { expiresIn: tokenPair.expiresIn },
    };
  }

  /**
   * Logout: destroy everything
   */
  async logout(
    sessionId: string,
    accessToken: string,
    userId: string
  ): Promise<{ cookies: string[] }> {
    // Destroy session
    await this.sessionStore.destroy(sessionId);

    // Revoke all refresh tokens for user
    await this.refreshManager.revokeAllForUser(userId, 'logout');

    // Clear cookies by setting maxAge=0
    const cookies = [
      this.cookie.serialize({
        name: 'access_token', value: '', maxAge: 0, path: '/',
        httpOnly: true, secure: true, sameSite: 'Lax',
      }),
      this.cookie.serialize({
        name: 'refresh_token', value: '', maxAge: 0, path: '/api/auth/refresh',
        httpOnly: true, secure: true, sameSite: 'Strict',
      }),
      this.cookie.serialize({
        name: 'session_id', value: '', maxAge: 0, path: '/',
        httpOnly: true, secure: true, sameSite: 'Lax',
      }),
      `_csrf=; Max-Age=0; Path=/; SameSite=Lax`,
    ];

    return { cookies };
  }

  /**
   * Global logout: destroy all sessions for user
   */
  async globalLogout(userId: string): Promise<void> {
    await this.sessionStore.destroyAllForUser(userId);
    await this.refreshManager.revokeAllForUser(userId, 'global_logout');
  }
}

Interview Questions

Q1: Why use refresh token rotation instead of long-lived access tokens?

A: Long-lived access tokens are dangerous because JWTs are stateless — once issued, they're valid until expiry. If stolen, the attacker has persistent access. Refresh token rotation solves this: access tokens are short-lived (15 min), and each refresh exchange issues a new refresh token while invalidating the old one. If an attacker steals a refresh token, the legitimate user's next refresh attempt detects the reuse (the stolen token was already consumed), triggering revocation of the entire token family. This limits the damage window to at most one refresh interval.

Q2: What's the difference between session-based and token-based authentication, and when do you choose each?

A: Session-based: server stores state (in Redis/DB), client holds an opaque session ID cookie. Pros — instant revocation, smaller cookies, server controls lifetime. Cons — requires shared session store for horizontal scaling, stickier state. Token-based (JWT): server is stateless, token contains all claims. Pros — no server-side storage, easy horizontal scaling, good for microservices. Cons — can't revoke until expiry without a blacklist (which reintroduces state). In practice, most production systems use a hybrid: short-lived JWTs for stateless API access plus server-side refresh tokens for revocation capability.

A: The server sets a CSRF token both as a cookie AND returns it in the response body. On mutation requests, the client must send the token in a custom header (e.g., X-CSRF-Token) matching the cookie value. An attacker's cross-origin form submission can cause the browser to send the cookie automatically, but the attacker can't read the cookie value (due to same-origin policy) and therefore can't set the matching custom header. The server compares both values — if they don't match, the request is rejected. SameSite cookies provide additional defense-in-depth.

Q4: Why is timing-safe comparison critical for token verification?

A: Standard string comparison (===) short-circuits on the first differing byte. An attacker can measure response times to deduce how many bytes matched, eventually reconstructing the token byte by byte (a timing side-channel attack). Timing-safe comparison XORs every byte of both strings and only checks the accumulated result at the end, ensuring the function takes the same amount of time regardless of where the mismatch occurs. This is essential for session IDs, CSRF tokens, and HMAC signatures.

Q5: How do you handle key rotation for JWTs in a distributed system?

A: Use a key management system with Key IDs (kid). Each JWT's header includes the kid of the key that signed it. During rotation: (1) generate new key, mark it active for signing; (2) mark old key as "rotated" — it can still verify but won't sign new tokens; (3) after a grace period (2× max token lifetime), revoke old key. Distribute keys via JWKS endpoint that all services poll. This ensures tokens signed with the old key remain valid during the transition, while new tokens use the new key. Never revoke immediately — in-flight tokens would all break.


Real-World Problems & How to Solve Them

Problem 1: Active users are logged out even while browsing

Symptom: Users making regular requests still get logged out exactly at session TTL.

Root cause: Sliding expiration is configured, but read paths don't consistently update both Redis TTL and expiresAt.

Fix — refresh expiry on every authenticated read:

async function readSessionWithSlidingTTL(
  redis: RedisClient,
  sessionId: string,
  ttlSeconds: number
): Promise<Session | null> {
  const key = `sess:${sessionId}`;
  const raw = await redis.get(key);
  if (!raw) return null;

  const session = JSON.parse(raw) as Session;
  if (session.expiresAt <= Date.now()) {
    await redis.del(key);
    return null;
  }

  session.lastAccessedAt = Date.now();
  session.expiresAt = Date.now() + ttlSeconds * 1000;
  await redis.set(key, JSON.stringify(session), { EX: ttlSeconds });
  return session;
}

Problem 2: Session fixation after login

Symptom: Security review finds that pre-login and post-login requests use the same session ID.

Root cause: Authentication upgrades an anonymous session without rotating the session identifier.

Fix — rotate session ID immediately after successful auth:

async function rotateSessionAfterAuth(
  store: SessionStore,
  session: Session,
  userId: string
): Promise<string> {
  const newId = new SessionIdGenerator().generate();

  await store.destroy(session.id);
  await store.create({
    ...session,
    id: newId,
    userId,
    createdAt: Date.now(),
    lastAccessedAt: Date.now(),
    rotationCount: session.rotationCount + 1,
  });

  return newId;
}

Problem 3: Stolen refresh token keeps working

Symptom: A reused refresh token still mints access tokens from another device.

Root cause: Rotation is implemented, but token family state is not tracked; replayed old tokens are treated as valid.

Fix — enforce one-time refresh tokens with family revocation on reuse:

type RefreshStatus = 'active' | 'consumed' | 'revoked';

interface RefreshTokenRecord {
  jti: string;
  familyId: string;
  userId: string;
  status: RefreshStatus;
  replacedBy?: string;
  expiresAt: number;
}

interface TokenStore {
  get(jti: string): Promise<RefreshTokenRecord | null>;
  update(jti: string, patch: Partial<RefreshTokenRecord>): Promise<void>;
  insert(record: RefreshTokenRecord): Promise<void>;
  revokeFamily(familyId: string): Promise<void>;
}

async function consumeRefreshToken(
  store: TokenStore,
  presentedJti: string,
  nextJti: string
): Promise<void> {
  const token = await store.get(presentedJti);
  if (!token || token.expiresAt <= Date.now()) {
    throw new Error('invalid_refresh_token');
  }

  if (token.status !== 'active') {
    await store.revokeFamily(token.familyId);
    throw new Error('refresh_token_reuse_detected');
  }

  await store.update(token.jti, { status: 'consumed', replacedBy: nextJti });
  await store.insert({
    jti: nextJti,
    familyId: token.familyId,
    userId: token.userId,
    status: 'active',
    expiresAt: Date.now() + 30 * 24 * 60 * 60 * 1000,
  });
}

Problem 4: JWT verification fails during key rotation

Symptom: Some services reject valid tokens immediately after rotating signing keys.

Root cause: Verifiers only trust the newest key and ignore kid-based grace-period verification.

Fix — verify using kid from active + grace keys:

import { createHmac, timingSafeEqual } from 'crypto';

async function verifyHs256WithKid(
  token: string,
  keysByKid: Record<string, string>
): Promise<boolean> {
  const [headerB64, payloadB64, signatureB64] = token.split('.');
  if (!headerB64 || !payloadB64 || !signatureB64) return false;

  const header = JSON.parse(
    Buffer.from(headerB64, 'base64url').toString('utf8')
  ) as { kid?: string; alg?: string };

  if (header.alg !== 'HS256' || !header.kid) return false;
  const secret = keysByKid[header.kid];
  if (!secret) return false;

  const expected = createHmac('sha256', secret)
    .update(`${headerB64}.${payloadB64}`)
    .digest('base64url');

  return timingSafeEqual(Buffer.from(expected), Buffer.from(signatureB64));
}

Problem 5: CSRF checks fail intermittently in SPA requests

Symptom: Mutating API calls return 403 only in browsers, but pass in Postman.

Root cause: Frontend requests omit credentials and the CSRF header, breaking the double-submit pattern.

Fix — always send cookie + mirrored header token:

// frontend
async function postWithCsrf(url: string, body: unknown, csrfToken: string) {
  return fetch(url, {
    method: 'POST',
    credentials: 'include',
    headers: {
      'content-type': 'application/json',
      'x-csrf-token': csrfToken,
    },
    body: JSON.stringify(body),
  });
}

// backend
function assertCsrf(cookieToken: string | undefined, headerToken: string | undefined): void {
  if (!cookieToken || !headerToken) throw new Error('csrf_missing');

  const a = Buffer.from(cookieToken);
  const b = Buffer.from(headerToken);
  if (a.length !== b.length || !require('crypto').timingSafeEqual(a, b)) {
    throw new Error('csrf_mismatch');
  }
}

Problem 6: Secret comparisons leak timing information

Symptom: Pen-test reports measurable response-time differences for invalid tokens.

Root cause: Secret values are compared with ===, which exits on first mismatch.

Fix — use constant-time comparison for all auth secrets:

import { timingSafeEqual } from 'crypto';

function safeEquals(a: string, b: string): boolean {
  const left = Buffer.from(a);
  const right = Buffer.from(b);
  if (left.length !== right.length) return false;
  return timingSafeEqual(left, right);
}

Problem 7: Users exceed max concurrent sessions

Symptom: Session limit is configured, but users accumulate old sessions indefinitely.

Root cause: User-session set is tracked, but stale IDs are never cleaned before enforcing limits.

Fix — prune stale sessions, then evict oldest active sessions:

async function enforceSessionLimit(
  redis: RedisClient,
  userId: string,
  maxSessions: number
): Promise<void> {
  const userKey = `user_sess:${userId}`;
  const ids = await redis.smembers(userKey);

  const sessions: Session[] = [];
  for (const id of ids) {
    const raw = await redis.get(`sess:${id}`);
    if (!raw) {
      await redis.srem(userKey, id);
      continue;
    }
    sessions.push(JSON.parse(raw) as Session);
  }

  sessions
    .sort((a, b) => a.lastAccessedAt - b.lastAccessedAt)
    .slice(0, Math.max(0, sessions.length - maxSessions))
    .forEach(async (s) => {
      await redis.del(`sess:${s.id}`);
      await redis.srem(userKey, s.id);
    });
}

Key Takeaways

  1. Session IDs need 256 bits of entropy from a CSPRNG — predictable IDs enable session hijacking.
  2. Session rotation after authentication is mandatory to prevent session fixation attacks.
  3. Refresh token rotation with family tracking detects token theft by identifying reuse of consumed tokens.
  4. JWTs are not revocable by default — you need a blacklist, version counter, or short lifetimes (or all three).
  5. Timing-safe comparison must be used for all secret comparisons — session IDs, tokens, HMAC signatures.
  6. CSRF protection requires the double-submit pattern or synchronizer tokens; SameSite cookies alone are insufficient.
  7. Cookie security attributes (httpOnly, Secure, SameSite, Path) form independent layers — use all of them.
  8. Key rotation must include a grace period where old keys can still verify; immediate revocation breaks in-flight tokens.
  9. Session fingerprinting detects hijacking by comparing browser characteristics across requests.
  10. Always encrypt sensitive cookie values with AES-GCM; signing only detects tampering, it doesn't hide data.

What did you think?

© 2026 Vidhya Sagar Thakur. All rights reserved.