How does session management work in Express.js?
Session management in Express.js stores user-specific data on the server across stateless HTTP requests, using a session ID cookie to identify the matching server-side record.
Theory
TL;DR
- Analogy: a coat check. You hand over your coat (data), get a ticket (cookie), reclaim it later by showing the ticket.
- The cookie holds only a signed ID, never the actual data. All data stays server-side.
connect.sidcarries the session ID;req.sessiongives you the stored object on every request.- Use sessions when the server needs to control state (login, revocation). Use JWT for stateless APIs.
- Default MemoryStore works only in development. Redis or Postgres for production.
Quick example
const express = require('express');
const session = require('express-session');
const app = express();
app.use(session({
secret: process.env.SESSION_SECRET, // Signs the session ID cookie
resave: false, // Skip write if session unchanged
saveUninitialized: false, // Don't create sessions for anonymous users
cookie: { maxAge: 60000, httpOnly: true, sameSite: 'lax' }
}));
app.post('/login', (req, res) => {
req.session.userId = '123'; // Data stored on the server
res.json({ ok: true }); // Client receives: connect.sid=s%3Aabc.xyz
});
app.get('/profile', (req, res) => {
if (!req.session.userId) return res.status(401).json({ error: 'Not logged in' });
res.json({ user: req.session.userId }); // Looked up by cookie ID
});The middleware attaches req.session on every request. You write to it like a plain object. Express-session handles reading, writing, and signing behind the scenes.
How session storage actually works
When a request arrives, express-session reads the connect.sid cookie and verifies the HMAC-SHA256 signature against your secret. If valid, it fetches the session object from the store (by default, an in-memory JS object). At the end of the request cycle, it writes any changes back to the store.
The cookie itself carries only the signed ID. Nothing sensitive. All actual data stays server-side. That is the entire point of the pattern.
Node's event-driven request pipeline means store reads and writes happen async, so they don't block other requests. With Redis, this is fast enough for high-traffic apps.
Key difference from client-side storage
localStorage and client-side tokens expose everything to JavaScript and XSS attacks. A session cookie with httpOnly: true is not readable by scripts at all. Beyond that, the server controls expiration and revocation by simply deleting the session record. The cookie stays small (just an ID), so there is no payload bloat on every request.
When to use sessions
- User authentication: store login state and roles server-side. Server can invalidate instantly.
- Shopping cart: persist items before checkout without exposing data to the client.
- A/B testing: track user groups server-side so clients cannot tamper with their assignment.
- Avoid for public APIs consumed by SPAs or mobile apps. JWT is a better fit there, no server storage needed.
- For clustered deployments or sustained high traffic (above roughly 10 req/s), pair sessions with Redis. MemoryStore won't hold up.
Session stores
The default MemoryStore leaks memory in production and breaks in clustered setups. Each Node process has its own memory, so a login on worker 1 fails on worker 2. This is the most common complaint about sessions on Stack Overflow.
| Store | Package | Use when |
|---|---|---|
| MemoryStore | Built-in | Dev only |
| Redis | connect-redis | Production, any scale |
| PostgreSQL | connect-pg-simple | Already on Postgres |
| MongoDB | connect-mongo | Already on MongoDB |
const RedisStore = require('connect-redis').default;
const { createClient } = require('redis');
const redisClient = createClient({ url: process.env.REDIS_URL });
await redisClient.connect();
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: { secure: true, sameSite: 'lax', httpOnly: true }
}));Sessions vs JWT
| Sessions | JWT | |
|---|---|---|
| Storage | Server-side | Client-side |
| Scalability | Needs a shared store | Stateless |
| Revocation | Easy (delete the record) | Hard (needs a blacklist) |
| Cookie size | Small (ID only) | Larger token |
| Best for | Server-rendered apps, admin panels | APIs, SPAs, microservices |
The short version: if you need to revoke access immediately (force logout, ban a user), sessions win. If you're building distributed systems or microservices where a shared store isn't practical, JWT is simpler.
Common mistakes
Hardcoded secret in code
// Wrong - one Git commit exposes this
secret: 'keyboard cat'
// Right
secret: process.env.SESSION_SECRETIf this ends up in a public repo, attackers can recalculate the HMAC signature and forge any session cookie.
saveUninitialized: true
The old default. This creates a session record for every anonymous visitor and every bot. MemoryStore fills up fast, OOM crashes follow. Set it to false and sessions only get created after you write something to req.session.
No shared store in a cluster
// Breaks in pm2 cluster mode or Docker with multiple replicas
app.use(session({ /* no store option */ }));Each process keeps its own in-memory sessions. User logs in on worker 1, next request hits worker 2, session is gone. Switch to connect-redis or connect-pg-simple.
secure: true in development
Cookies with secure: true only travel over HTTPS. They are never sent on localhost HTTP. The fix:
cookie: { secure: process.env.NODE_ENV === 'production' }Missing req.session.regenerate() after login
Session fixation: an attacker pre-sets a known connect.sid cookie before the victim logs in. After login, both sides share that session. Calling req.session.regenerate() right after successful auth issues a new ID and copies the data over.
app.post('/login', async (req, res) => {
const user = await authenticate(req.body);
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
req.session.regenerate(err => { // New ID prevents fixation
if (err) return res.status(500).json({ error: 'Session error' });
req.session.userId = user.id;
req.session.roles = user.roles;
res.json({ ok: true });
});
});Real-world usage
- Passport.js: calls
serializeUserto store only the user ID inreq.session.passport, thendeserializeUserto load the full user on each request. - Socket.io: shares the Express session by passing the middleware directly into
io.use(). - Strapi / KeystoneJS: Redis-backed sessions for admin panel auth.
- Next.js custom server: session middleware runs before
next()to gate SSR routes.
Follow-up questions
Q: What is a session fixation attack?
A: An attacker sets a known connect.sid cookie before the victim logs in. After login, both sides share that session. Fix with req.session.regenerate() on every successful auth - it issues a new ID and copies the existing data.
Q: When should you switch from MemoryStore to Redis?
A: As soon as you leave development or run more than one process. In a cluster, MemoryStore isolates each worker. On a single process with real traffic it leaks memory over time. Redis handles both problems with one change.
Q: How does resave: false improve performance?
A: It skips the store write if session data did not change during the request. In read-heavy apps this cuts DB or Redis writes by 50-70%.
Q: How do you revoke sessions across all devices?
A: Store an activeSessions Set in Redis per user ID, with each session ID as a member. On login, add the new session ID. On logout-all, delete every member and the set. On each request, check membership before allowing access.
Q: sameSite: 'strict' vs 'lax' for CSRF protection?
A: 'strict' blocks the cookie on all cross-site requests, including top-level navigation like clicking a link. 'lax' allows it on safe top-level navigation but blocks cross-site POSTs. Most apps use 'lax' and pair it with a CSRF token for mutation endpoints.
Examples
Basic login, protected route, and logout
const express = require('express');
const session = require('express-session');
const app = express();
app.use(express.json());
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
maxAge: 24 * 60 * 60 * 1000 // 24 hours
}
}));
app.post('/login', async (req, res) => {
const { email, password } = req.body;
const user = await db.users.findOne({ email });
if (!user || !await bcrypt.compare(password, user.passwordHash)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
req.session.regenerate(err => {
if (err) return res.status(500).json({ error: 'Session error' });
req.session.userId = user.id;
req.session.roles = user.roles;
res.json({ ok: true });
});
});
app.get('/me', (req, res) => {
if (!req.session.userId) return res.status(401).json({ error: 'Not authenticated' });
res.json({ userId: req.session.userId, roles: req.session.roles });
});
app.post('/logout', (req, res) => {
req.session.destroy(err => {
if (err) return res.status(500).json({ error: 'Logout failed' });
res.clearCookie('connect.sid');
res.json({ ok: true });
});
});The key detail is req.session.regenerate() before writing user data. Skipping that step is exactly how session fixation attacks succeed in practice.
Production setup with Redis and reusable auth middleware
const RedisStore = require('connect-redis').default;
const { createClient } = require('redis');
const redisClient = createClient({ url: process.env.REDIS_URL });
await redisClient.connect();
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: { secure: true, httpOnly: true, sameSite: 'lax', maxAge: 86400000 }
}));
function requireAuth(req, res, next) {
if (!req.session.userId) return res.status(401).json({ error: 'Not authenticated' });
next();
}
function requireRole(role) {
return (req, res, next) => {
if (!req.session.roles?.includes(role)) {
return res.status(403).json({ error: 'Forbidden' });
}
next();
};
}
app.get('/admin', requireAuth, requireRole('admin'), (req, res) => {
res.json({ message: 'Admin panel' });
});I've seen teams inline if (!req.session.userId) in every route handler. That works until you need to change auth logic in 40 places at once.
Revoking sessions across all devices
// On login: register session ID in a per-user Redis Set
app.post('/login', async (req, res) => {
// ... authenticate user ...
req.session.regenerate(async err => {
if (err) return res.status(500).json({ error: 'Error' });
req.session.userId = user.id;
// Track this session ID under the user's key
await redisClient.sAdd(`user:${user.id}:sessions`, req.session.id);
res.json({ ok: true });
});
});
// Logout everywhere: delete all sessions for this user
app.post('/logout-all', requireAuth, async (req, res) => {
const sessionIds = await redisClient.sMembers(`user:${req.session.userId}:sessions`);
for (const id of sessionIds) {
await redisClient.del(`sess:${id}`); // connect-redis key prefix
}
await redisClient.del(`user:${req.session.userId}:sessions`);
res.json({ ok: true });
});This answers the classic senior interview question directly. Cookies are ID-based, so clearing cookies on one device removes only that device's ID. Revoking all devices requires server-side tracking like this.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.