Skip to content
Unverified — AI-generated content. Help verify this page

Session Management

Sessions are the mechanism by which a server remembers who you are across multiple HTTP requests. HTTP is stateless — every request is independent. Sessions bridge that gap by associating a random identifier (the session ID) with server-side state. While JWTs push state to the client, sessions keep state on the server, giving you instant revocation, smaller cookies, and no information leakage.

Session Architecture

Why Server-Side Sessions

AspectServer-Side SessionsJWTs
RevocationInstant (delete from store)Complex (blocklist needed)
SizeSmall cookie (~32 bytes)Large (~1-2 KB per request)
Server stateRequired (Redis/DB)Not required
Information leakageNone (opaque ID)Claims visible in base64
Cross-serviceRequires shared storeSelf-contained
ScalabilityNeeds centralized storeStateless

When to Use Sessions

Server-side sessions are the right choice when you have a monolithic application, need instant revocation (e.g., "log out all devices"), or want to avoid the complexity of JWT blocklists. They are also simpler to implement correctly.

Session ID Generation

The session ID must be cryptographically random and sufficiently long to prevent brute-force guessing.

typescript
import crypto from 'crypto';

// ─── Secure Session ID Generation ────────────────────────────

function generateSessionId(): string {
  // 32 bytes = 256 bits of entropy — brute-force infeasible
  return crypto.randomBytes(32).toString('hex');
}

// BAD: Predictable session IDs
function badSessionId(): string {
  return `session_${Date.now()}`; // Predictable — attacker can guess
}

function alsobadSessionId(): string {
  return `session_${Math.random()}`; // Math.random() is not cryptographically secure
}

// Session ID requirements:
// - At least 128 bits (16 bytes) of entropy (OWASP recommends 256 bits)
// - Generated with a CSPRNG (crypto.randomBytes, not Math.random)
// - URL-safe characters (hex or base64url encoding)
// - No embedded information (no user ID, timestamp, etc.)

Redis-Backed Session Store

typescript
import { Redis } from 'ioredis';
import { Request, Response, NextFunction } from 'express';

// ─── Session Data Types ──────────────────────────────────────

interface SessionData {
  userId: string;
  role: string;
  orgId?: string;
  permissions: string[];
  createdAt: number;
  lastAccessedAt: number;
  expiresAt: number;
  ipAddress: string;
  userAgent: string;
  csrfToken: string;
  metadata?: Record<string, unknown>;
}

interface SessionConfig {
  prefix: string;
  defaultTTL: number;       // seconds
  absoluteTimeout: number;  // seconds — max session lifetime
  idleTimeout: number;      // seconds — inactivity timeout
  renewThreshold: number;   // seconds — regenerate ID when this old
  cookieName: string;
  cookieDomain?: string;
  cookiePath: string;
  secure: boolean;
  sameSite: 'strict' | 'lax' | 'none';
}

const DEFAULT_CONFIG: SessionConfig = {
  prefix: 'sess:',
  defaultTTL: 3600,           // 1 hour
  absoluteTimeout: 86400,     // 24 hours
  idleTimeout: 1800,          // 30 minutes
  renewThreshold: 300,        // 5 minutes
  cookieName: '__Host-session',
  cookiePath: '/',
  secure: true,
  sameSite: 'strict',
};

// ─── Session Store ────────────────────────────────────────────

class RedisSessionStore {
  private redis: Redis;
  private config: SessionConfig;

  constructor(redis: Redis, config: Partial<SessionConfig> = {}) {
    this.redis = redis;
    this.config = { ...DEFAULT_CONFIG, ...config };
  }

  private key(sessionId: string): string {
    return `${this.config.prefix}${sessionId}`;
  }

  private userSessionsKey(userId: string): string {
    return `user_sessions:${userId}`;
  }

  // Create a new session
  async create(
    data: Omit<SessionData, 'createdAt' | 'lastAccessedAt' | 'expiresAt' | 'csrfToken'>
  ): Promise<{ sessionId: string; csrfToken: string }> {
    const sessionId = generateSessionId();
    const csrfToken = crypto.randomBytes(32).toString('hex');
    const now = Date.now();

    const session: SessionData = {
      ...data,
      createdAt: now,
      lastAccessedAt: now,
      expiresAt: now + this.config.absoluteTimeout * 1000,
      csrfToken,
    };

    const pipeline = this.redis.pipeline();

    // Store session data
    pipeline.setex(
      this.key(sessionId),
      this.config.defaultTTL,
      JSON.stringify(session)
    );

    // Track user's active sessions (for "list all sessions" feature)
    pipeline.sadd(this.userSessionsKey(data.userId), sessionId);

    await pipeline.exec();

    return { sessionId, csrfToken };
  }

  // Retrieve and validate a session
  async get(sessionId: string): Promise<SessionData | null> {
    const raw = await this.redis.get(this.key(sessionId));
    if (!raw) return null;

    const session: SessionData = JSON.parse(raw);
    const now = Date.now();

    // Check absolute timeout
    if (now > session.expiresAt) {
      await this.destroy(sessionId, session.userId);
      return null;
    }

    // Check idle timeout
    if (now - session.lastAccessedAt > this.config.idleTimeout * 1000) {
      await this.destroy(sessionId, session.userId);
      return null;
    }

    // Update last accessed time (sliding window)
    session.lastAccessedAt = now;
    await this.redis.setex(
      this.key(sessionId),
      this.config.defaultTTL,
      JSON.stringify(session)
    );

    return session;
  }

  // Regenerate session ID (prevents session fixation)
  async regenerate(
    oldSessionId: string,
    preserveData: boolean = true
  ): Promise<string> {
    const session = await this.get(oldSessionId);
    if (!session) {
      throw new Error('Session not found');
    }

    const newSessionId = generateSessionId();
    const pipeline = this.redis.pipeline();

    if (preserveData) {
      // Copy data to new session ID
      pipeline.setex(
        this.key(newSessionId),
        this.config.defaultTTL,
        JSON.stringify(session)
      );
    }

    // Delete old session
    pipeline.del(this.key(oldSessionId));

    // Update user sessions set
    pipeline.srem(this.userSessionsKey(session.userId), oldSessionId);
    pipeline.sadd(this.userSessionsKey(session.userId), newSessionId);

    await pipeline.exec();

    return newSessionId;
  }

  // Destroy a specific session
  async destroy(sessionId: string, userId?: string): Promise<void> {
    if (userId) {
      await this.redis.srem(this.userSessionsKey(userId), sessionId);
    }
    await this.redis.del(this.key(sessionId));
  }

  // Destroy ALL sessions for a user (logout everywhere)
  async destroyAllForUser(userId: string): Promise<number> {
    const sessionIds = await this.redis.smembers(this.userSessionsKey(userId));

    if (sessionIds.length === 0) return 0;

    const pipeline = this.redis.pipeline();
    for (const sid of sessionIds) {
      pipeline.del(this.key(sid));
    }
    pipeline.del(this.userSessionsKey(userId));

    await pipeline.exec();
    return sessionIds.length;
  }

  // List all active sessions for a user
  async listUserSessions(userId: string): Promise<Array<{
    sessionId: string;
    ipAddress: string;
    userAgent: string;
    createdAt: number;
    lastAccessedAt: number;
    current: boolean;
  }>> {
    const sessionIds = await this.redis.smembers(this.userSessionsKey(userId));
    const sessions: Array<any> = [];

    for (const sid of sessionIds) {
      const raw = await this.redis.get(this.key(sid));
      if (raw) {
        const session: SessionData = JSON.parse(raw);
        sessions.push({
          sessionId: sid,
          ipAddress: session.ipAddress,
          userAgent: session.userAgent,
          createdAt: session.createdAt,
          lastAccessedAt: session.lastAccessedAt,
          current: false, // Caller sets this based on current session
        });
      } else {
        // Session expired — clean up the set
        await this.redis.srem(this.userSessionsKey(userId), sid);
      }
    }

    return sessions;
  }
}
typescript
import { CookieOptions } from 'express';

// ─── Cookie Security Settings ────────────────────────────────

function getSessionCookieOptions(config: SessionConfig): CookieOptions {
  return {
    httpOnly: true,       // JavaScript cannot access the cookie (XSS protection)
    secure: true,         // Only sent over HTTPS
    sameSite: 'strict',   // Not sent with cross-site requests (CSRF protection)
    path: '/',            // Available to all paths
    maxAge: config.defaultTTL * 1000, // Browser-side expiry
    // When using __Host- prefix, domain must NOT be set
    // __Host- prefix requires: secure=true, path=/
  };
}

// ─── Cookie Prefixes ──────────────────────────────────────────

// __Host-session — Most restrictive, recommended
// Requirements: Secure=true, Path=/, no Domain attribute
// Effect: Cookie is bound to the exact host, cannot be set by subdomains

// __Secure-session — Less restrictive
// Requirements: Secure=true
// Effect: Cookie only sent over HTTPS

// Avoid: session (no prefix) — no browser-enforced requirements

Never Set httpOnly to False for Session Cookies

If JavaScript can read the session cookie, any XSS vulnerability becomes a session hijacking vulnerability. There is no legitimate reason for client-side JavaScript to access the session identifier.

Session Fixation Prevention

Session fixation occurs when an attacker can set a known session ID for a victim. The victim authenticates, and the attacker uses the pre-set session ID to access the victim's account.

Prevention: Regenerate Session ID on Authentication

typescript
// ─── Session Middleware ──────────────────────────────────────

function sessionMiddleware(store: RedisSessionStore, config: SessionConfig) {
  return async (req: Request, res: Response, next: NextFunction) => {
    const sessionId = req.cookies[config.cookieName];

    if (sessionId) {
      const session = await store.get(sessionId);
      if (session) {
        (req as any).session = session;
        (req as any).sessionId = sessionId;
      }
    }

    next();
  };
}

// ─── Login Handler with Session Fixation Prevention ──────────

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

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

  // CRITICAL: If a session already exists, destroy it BEFORE creating a new one
  const existingSessionId = req.cookies[config.cookieName];
  if (existingSessionId) {
    await store.destroy(existingSessionId);
  }

  // Create a brand new session with a new ID
  const { sessionId, csrfToken } = await store.create({
    userId: user.id,
    role: user.role,
    permissions: user.permissions,
    ipAddress: req.ip!,
    userAgent: req.get('user-agent') || 'unknown',
  });

  // Set the new session cookie
  res.cookie(config.cookieName, sessionId, getSessionCookieOptions(config));

  // Return CSRF token (stored in session, validated on state-changing requests)
  res.json({
    csrfToken,
    user: { id: user.id, name: user.name, role: user.role },
  });
});

CSRF Protection with Sessions

typescript
// ─── CSRF Middleware ─────────────────────────────────────────

function csrfProtection(store: RedisSessionStore, config: SessionConfig) {
  return async (req: Request, res: Response, next: NextFunction) => {
    // Skip for safe methods
    if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
      return next();
    }

    const session = (req as any).session as SessionData | undefined;
    if (!session) {
      return res.status(403).json({ error: 'No session' });
    }

    // Check CSRF token from header
    const csrfToken = req.headers['x-csrf-token'] as string;
    if (!csrfToken) {
      return res.status(403).json({ error: 'Missing CSRF token' });
    }

    // Constant-time comparison to prevent timing attacks
    const expected = Buffer.from(session.csrfToken);
    const received = Buffer.from(csrfToken);

    if (expected.length !== received.length || !crypto.timingSafeEqual(expected, received)) {
      return res.status(403).json({ error: 'Invalid CSRF token' });
    }

    next();
  };
}

Concurrent Session Control

typescript
// ─── Limit Active Sessions Per User ──────────────────────────

class SessionController {
  private store: RedisSessionStore;
  private maxSessionsPerUser: number;

  constructor(store: RedisSessionStore, maxSessions: number = 5) {
    this.store = store;
    this.maxSessionsPerUser = maxSessions;
  }

  async createSession(
    userId: string,
    data: Omit<SessionData, 'createdAt' | 'lastAccessedAt' | 'expiresAt' | 'csrfToken'>
  ): Promise<{ sessionId: string; csrfToken: string; evicted: string[] }> {
    const activeSessions = await this.store.listUserSessions(userId);
    const evicted: string[] = [];

    // If at the limit, evict the oldest session(s)
    if (activeSessions.length >= this.maxSessionsPerUser) {
      const sorted = activeSessions.sort(
        (a, b) => a.lastAccessedAt - b.lastAccessedAt
      );
      const toEvict = sorted.slice(
        0,
        activeSessions.length - this.maxSessionsPerUser + 1
      );

      for (const session of toEvict) {
        await this.store.destroy(session.sessionId, userId);
        evicted.push(session.sessionId);
      }
    }

    const result = await this.store.create(data);
    return { ...result, evicted };
  }
}

Session Lifecycle

Complete Express.js Integration

typescript
import express from 'express';
import cookieParser from 'cookie-parser';
import { Redis } from 'ioredis';

const app = express();
const redis = new Redis();

app.use(express.json());
app.use(cookieParser());

const config = DEFAULT_CONFIG;
const store = new RedisSessionStore(redis, config);
const controller = new SessionController(store, 5);

// Apply session middleware to all routes
app.use(sessionMiddleware(store, config));

// ─── Login ───────────────────────────────────────────────────

app.post('/auth/login', async (req, res) => {
  const { email, password, mfaCode } = req.body;

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

  // Check MFA if enabled
  if (user.mfaEnabled) {
    if (!mfaCode || !verifyTOTP(user.mfaSecret, mfaCode)) {
      return res.status(401).json({ error: 'Invalid MFA code' });
    }
  }

  // Destroy existing session (session fixation prevention)
  const existingSessionId = req.cookies[config.cookieName];
  if (existingSessionId) {
    await store.destroy(existingSessionId, user.id);
  }

  // Create new session with concurrent session control
  const { sessionId, csrfToken, evicted } = await controller.createSession(
    user.id,
    {
      userId: user.id,
      role: user.role,
      permissions: user.permissions,
      ipAddress: req.ip!,
      userAgent: req.get('user-agent') || 'unknown',
    }
  );

  if (evicted.length > 0) {
    console.log(`Evicted ${evicted.length} old sessions for user ${user.id}`);
  }

  res.cookie(config.cookieName, sessionId, getSessionCookieOptions(config));

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

// ─── Logout (Current Session) ────────────────────────────────

app.post('/auth/logout', csrfProtection(store, config), async (req, res) => {
  const sessionId = (req as any).sessionId;
  const session = (req as any).session;

  if (sessionId && session) {
    await store.destroy(sessionId, session.userId);
  }

  res.clearCookie(config.cookieName, { path: '/' });
  res.json({ message: 'Logged out' });
});

// ─── Logout (All Sessions) ──────────────────────────────────

app.post('/auth/logout-all', csrfProtection(store, config), async (req, res) => {
  const session = (req as any).session;
  if (!session) {
    return res.status(401).json({ error: 'Not authenticated' });
  }

  const count = await store.destroyAllForUser(session.userId);

  res.clearCookie(config.cookieName, { path: '/' });
  res.json({ message: `Logged out from ${count} sessions` });
});

// ─── List Active Sessions ────────────────────────────────────

app.get('/auth/sessions', async (req, res) => {
  const session = (req as any).session;
  const currentSessionId = (req as any).sessionId;

  if (!session) {
    return res.status(401).json({ error: 'Not authenticated' });
  }

  const sessions = await store.listUserSessions(session.userId);

  // Mark the current session
  const formatted = sessions.map(s => ({
    ...s,
    current: s.sessionId === currentSessionId,
    sessionId: undefined, // Don't expose session IDs to the client
  }));

  res.json({ sessions: formatted });
});

// ─── Revoke Specific Session ─────────────────────────────────

app.delete('/auth/sessions/:index', csrfProtection(store, config), async (req, res) => {
  const session = (req as any).session;
  if (!session) {
    return res.status(401).json({ error: 'Not authenticated' });
  }

  const sessions = await store.listUserSessions(session.userId);
  const index = parseInt(req.params.index, 10);

  if (index < 0 || index >= sessions.length) {
    return res.status(404).json({ error: 'Session not found' });
  }

  const target = sessions[index];
  await store.destroy(target.sessionId, session.userId);

  res.json({ message: 'Session revoked' });
});

// ─── Protected Route ─────────────────────────────────────────

app.get('/api/profile', async (req, res) => {
  const session = (req as any).session;
  if (!session) {
    return res.status(401).json({ error: 'Not authenticated' });
  }

  res.json({
    userId: session.userId,
    role: session.role,
    loginTime: new Date(session.createdAt).toISOString(),
  });
});

Session Security Comparison

Security Checklist

  • [ ] Generate session IDs with crypto.randomBytes (256 bits minimum)
  • [ ] Use Redis or a database as the session store (never in-memory for production)
  • [ ] Set httpOnly, Secure, SameSite=Strict on session cookies
  • [ ] Use __Host- cookie prefix for maximum browser protection
  • [ ] Regenerate session ID after authentication (session fixation prevention)
  • [ ] Regenerate session ID after privilege changes (e.g., role elevation)
  • [ ] Implement idle timeout (30 minutes) and absolute timeout (24 hours)
  • [ ] Implement CSRF protection with a per-session CSRF token
  • [ ] Limit concurrent sessions per user (e.g., max 5)
  • [ ] Provide "log out all devices" functionality
  • [ ] Log session creation, destruction, and suspicious activity
  • [ ] Never put session IDs in URLs or log files
  • [ ] Use constant-time comparison for CSRF token validation

"What I cannot create, I do not understand." — Richard Feynman