Session management done right: cookies, JWTs, and the hybrid approach

The "cookies vs JWTs" debate has generated more heat than light. Both are valid, both have failure modes, and the right choice depends on what you're building. More importantly, the most practical production architecture is usually a hybrid of both. This post covers what each approach actually does, where each breaks down, and how to combine them effectively.

Stateful sessions: the traditional model

In a stateful session system, the server generates an opaque session token, stores session state in a database or in-memory store (Redis), and the client presents the token on each request. The server looks it up, finds the associated state, and decides what to do.

// Create session on login
async function createSession(userId: string, req: Request, redis: Redis): Promise<string> {
  const sessionId = generateSecureToken(32); // 32 bytes = 256 bits
  const session = {
    userId,
    createdAt: Date.now(),
    lastActiveAt: Date.now(),
    userAgent: req.headers['user-agent'],
    ip: req.ip,
  };

  // Store in Redis with idle TTL
  const IDLE_TTL = 30 * 60; // 30 minutes idle timeout
  await redis.setex(`session:${sessionId}`, IDLE_TTL, JSON.stringify(session));

  return sessionId;
}

// Validate on each request
async function getSession(sessionId: string, redis: Redis): Promise<Session | null> {
  const raw = await redis.get(`session:${sessionId}`);
  if (!raw) return null;

  const session = JSON.parse(raw) as Session;

  // Slide the idle TTL on access
  await redis.expire(`session:${sessionId}`, 30 * 60);

  return session;
}

Advantages: Instant revocation (delete the key in Redis), server-side state means you can store anything, simple mental model.

Disadvantages: Every request hits the session store, creating a scalability bottleneck. Horizontal scaling requires session store replication or sticky sessions.

Stateless sessions: JWTs

A JWT encodes all session state inside the token itself, signed with a secret or private key. No server-side storage needed for validation — you just verify the signature and check the expiry.

import { SignJWT, jwtVerify } from 'jose';

const SECRET = new TextEncoder().encode(process.env.JWT_SECRET);

async function issueAccessToken(userId: string, orgId: string): Promise<string> {
  return new SignJWT({ sub: userId, org: orgId })
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime('15m')
    .setJti(crypto.randomUUID())
    .sign(SECRET);
}

async function verifyAccessToken(token: string) {
  try {
    const { payload } = await jwtVerify(token, SECRET);
    return payload;
  } catch {
    return null;
  }
}

Advantages: Stateless — any server can validate without hitting a database. Great for microservices where every service needs to know the caller's identity.

Disadvantages: Revocation is hard. A valid JWT is valid until expiry, period. If you need to invalidate a session immediately (account compromise, logout), you must add server-side state to check against — at which point you've re-introduced the stateful component you were trying to avoid.

The hybrid approach

The most practical production pattern for web apps:

  • HttpOnly cookie → session ID that references a record in Redis
  • On each request, the session lookup in Redis returns a short-lived access JWT (or just the session data directly)
  • The JWT is used for downstream service-to-service calls within the request lifecycle

This gives you instant revocation (delete the Redis record) while keeping downstream services stateless.

// Cookie settings — the correct full set
res.cookie('sid', sessionId, {
  httpOnly: true,      // not accessible to JavaScript
  secure: true,        // HTTPS only — no exceptions
  sameSite: 'strict',  // no cross-site requests at all
  path: '/',           // available across the whole app
  maxAge: 90 * 24 * 60 * 60 * 1000, // 90-day absolute max
  // domain: '.bastionary.com'  // uncomment only for cross-subdomain
});

SameSite explained

The sameSite attribute is your CSRF defense for cookie-based auth:

  • strict — Cookie is never sent on cross-site requests. Clicking a link from another site won't include it. Highest security, but can break "share this link" flows where the user is landing from an external site.
  • lax — Cookie is sent on top-level navigation (link clicks) but not on cross-site sub-requests (images, iframes, fetch). The browser default since 2020, and a reasonable compromise.
  • none — Always sent. Requires Secure. Only for explicitly cross-site use cases.

For most auth cookies, use strict. If your app has links shared externally that expect the user to be authenticated immediately on landing (like email magic links), you'll need either lax or a landing-page pattern that re-authenticates via a URL token.

Session fixation

Session fixation is an attack where the adversary sets a known session ID on the victim's browser (via URL parameter or cookie injection), waits for the victim to log in, and then uses that session ID — which is now authenticated — to impersonate them.

The fix is simple and non-negotiable: always issue a new session ID upon successful authentication:

async function loginUser(
  email: string,
  password: string,
  existingSessionId: string | undefined,
  req: Request,
  redis: Redis
): Promise<string> {
  // ... verify credentials ...

  // If there was an existing anonymous session, destroy it
  if (existingSessionId) {
    await redis.del(`session:${existingSessionId}`);
  }

  // Always generate a fresh session ID post-authentication
  const newSessionId = generateSecureToken(32);
  await createSession(userId, req, redis, newSessionId);
  return newSessionId;
}

Absolute vs idle timeout

Two independent timeout types serve different security goals:

  • Idle timeout — Session expires if inactive for N minutes. Protects against physical access (unattended laptop) and session theft where the attacker doesn't immediately use the session. Typical: 15–60 minutes for high-security apps, 30 days for consumer apps.
  • Absolute timeout — Session expires after N time regardless of activity. Forces re-authentication even for continuously active sessions. Limits the window of a long-lived stolen session. Typical: 8–24 hours for enterprise, 90–180 days for consumer.
function isSessionValid(session: Session): boolean {
  const now = Date.now();
  const IDLE_TIMEOUT = 30 * 60 * 1000;      // 30 minutes
  const ABSOLUTE_TIMEOUT = 90 * 24 * 60 * 60 * 1000; // 90 days

  if (now - session.lastActiveAt > IDLE_TIMEOUT) return false;
  if (now - session.createdAt > ABSOLUTE_TIMEOUT) return false;
  return true;
}

Device session management

Users expect to see a list of their active sessions and be able to terminate individual ones. This requires storing enough metadata on session creation to make the list useful:

CREATE TABLE sessions (
  id           UUID PRIMARY KEY,
  user_id      UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  created_at   TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  last_active  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  absolute_exp TIMESTAMPTZ NOT NULL,
  ip_address   INET,
  user_agent   TEXT,
  -- Parsed from user_agent for display
  device_type  TEXT,   -- 'desktop', 'mobile', 'tablet'
  browser      TEXT,   -- 'Chrome', 'Safari', 'Firefox'
  os           TEXT,   -- 'macOS', 'Windows', 'iOS'
  country      TEXT,   -- from IP geolookup
  revoked_at   TIMESTAMPTZ
);

In your dashboard, render these as "Signed in on Chrome on macOS · San Francisco, CA · 2 hours ago" with a "Sign out" button. Include a "Sign out all other sessions" button that revokes everything except the current session.

When a user changes their password or enables MFA, automatically revoke all other sessions. This is expected behavior and prevents an attacker who had a previous session from maintaining access after a credential update.