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. RequiresSecure. 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.