Session management is how your app tracks who's logged in. Get it wrong and attackers hijack sessions, fixate sessions, or sail past logout. Below: the patterns that work and the mistakes to avoid.
Cookie-Based Sessions vs Token-Based Sessions
| Cookie-Based | Token-Based (JWT) | |
|---|---|---|
| Storage | Server (DB/Redis) + session ID in cookie | Client (cookie or header) |
| State | Stateful (server stores session data) | Stateless (token contains data) |
| Revocation | Immediate (delete from store) | Delayed (wait for expiry or blocklist) |
| Scalability | Requires shared session store | No shared state needed |
| Best for | Traditional web apps | APIs, SPAs, mobile apps |
For server-rendered web apps, cookie-based sessions are simpler and easier to secure. Use tokens for APIs.
Reference: OWASP - Session Management Cheat Sheet
Secure Cookie Configuration
Every session cookie must have these attributes:
// Express / Next.js example
const sessionCookie = {
name: "__session",
value: sessionId,
httpOnly: true, // not accessible via JavaScript
secure: true, // only sent over HTTPS
sameSite: "lax", // CSRF protection
path: "/", // available on all routes
maxAge: 60 * 60 * 24, // 24 hours (in seconds)
};
| Attribute | Purpose | Value |
|---|---|---|
HttpOnly | Prevents XSS from reading the cookie | true |
Secure | Only sent over HTTPS | true |
SameSite | Prevents CSRF | Lax or Strict |
Path | Scope the cookie to a path | / (usually) |
Domain | Scope to domain | omit (defaults to current host) |
Max-Age | Expiry | shortest practical lifetime |
Reference: MDN - Set-Cookie
Generate Secure Session IDs
Session IDs must be random and unguessable:
import crypto from "crypto";
function generateSessionId(): string {
return crypto.randomBytes(32).toString("hex"); // 256 bits of entropy
}
Requirements:
- At least 128 bits of entropy (256 is better).
- Generated by a cryptographically secure random number generator.
- Not sequential, not derived from user data, not predictable.
Reference: OWASP - Session ID Properties
Session Rotation
Issue a new session ID after any privilege change to prevent session fixation attacks:
async function rotateSession(req: Request, userId: string) {
// Delete old session
const oldSessionId = getSessionIdFromCookie(req);
if (oldSessionId) {
await sessionStore.delete(oldSessionId);
}
// Create new session
const newSessionId = generateSessionId();
await sessionStore.create(newSessionId, { userId, createdAt: Date.now() });
return newSessionId; // set as new cookie
}
Rotate on:
- Login (most important)
- Privilege escalation (e.g., entering admin mode)
- Password change
- Any sensitive action
Session Expiration
Implement both idle timeout and absolute timeout:
const IDLE_TIMEOUT = 30 * 60 * 1000; // 30 minutes of inactivity
const ABSOLUTE_TIMEOUT = 24 * 60 * 60 * 1000; // 24 hours total
async function validateSession(sessionId: string): Promise<Session | null> {
const session = await sessionStore.get(sessionId);
if (!session) return null;
const now = Date.now();
// Absolute timeout
if (now - session.createdAt > ABSOLUTE_TIMEOUT) {
await sessionStore.delete(sessionId);
return null;
}
// Idle timeout
if (now - session.lastActivity > IDLE_TIMEOUT) {
await sessionStore.delete(sessionId);
return null;
}
// Update last activity
await sessionStore.update(sessionId, { lastActivity: now });
return session;
}
Logout Must Destroy the Session
Logout is not just clearing the cookie - you must destroy the server-side session:
async function logout(req: Request): Promise<Response> {
const sessionId = getSessionIdFromCookie(req);
if (sessionId) {
// 1. Delete server-side session
await sessionStore.delete(sessionId);
}
// 2. Clear the cookie
return new Response("Logged out", {
headers: {
"Set-Cookie": `__session=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0`,
},
});
}
Without server-side deletion, the session ID in the cookie is still valid if the user (or an attacker) replays it.
Logout Everywhere
Allow users to invalidate all their active sessions:
async function logoutEverywhere(userId: string) {
// Delete all sessions for this user
await sessionStore.deleteByUser(userId);
}
This is essential for:
- Password changes (force re-login on all devices).
- Account compromise response.
- User-initiated "sign out of all devices."
Concurrent Session Limits
Optionally limit the number of active sessions per user:
const MAX_SESSIONS = 5;
async function createSession(userId: string): Promise<string> {
const activeSessions = await sessionStore.getByUser(userId);
if (activeSessions.length >= MAX_SESSIONS) {
// Remove oldest session
const oldest = activeSessions.sort((a, b) => a.createdAt - b.createdAt)[0];
await sessionStore.delete(oldest.id);
}
const sessionId = generateSessionId();
await sessionStore.create(sessionId, { userId, createdAt: Date.now() });
return sessionId;
}
Session Storage
| Store | Speed | Persistence | Best For |
|---|---|---|---|
| Redis | Fast | Configurable (AOF/RDB) | Most production apps |
| Database (PostgreSQL) | Moderate | Full | Apps needing audit trail |
| In-memory | Fastest | None (lost on restart) | Development only |
Redis with TTL is the standard approach for session storage.
Cookie Prefixes
Use cookie prefixes for additional browser-enforced security:
__Host-session=abc123; Secure; HttpOnly; SameSite=Lax; Path=/
__Host-prefix: browser ensures the cookie isSecure, hasPath=/, and has noDomainattribute (only the exact host).__Secure-prefix: browser ensures the cookie isSecure.
Reference: MDN - Cookie Prefixes
Common Mistakes
- Storing session IDs in localStorage (vulnerable to XSS).
- Not rotating session ID after login (session fixation).
- Logout only clears the cookie without destroying the server-side session.
- No idle timeout (sessions last forever while the tab is open).
- Using predictable session IDs (sequential numbers, timestamps).
- Missing
HttpOnlyflag (cookie readable by JavaScript). - Missing
SameSiteattribute (vulnerable to CSRF in older browsers).
