Sessions and Cookies
A session cookie should carry only an opaque ID; the name, role and everything else stay in a server-side store.
res.setHeader('Set-Cookie', `sid=${id}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=3600`)HTTP has no memory. Each request arrives on its own with nothing to say it came from the same person as the last one. A cookie closes that gap. The server sends a Set-Cookie header once, and the browser repeats the value in a Cookie header on every later request to that site.
Put a long random ID in the cookie and nothing else. The user's name, email and role live on the server in a session store keyed by that ID. The cookie is the cloakroom ticket, not the coat.
The attributes that matter
HttpOnly— page scripts cannot read the cookie, so a cross-site scripting bug cannot steal it.Secure— the browser sends it only over HTTPS.SameSite=Lax— it is not sent on cross-site form posts, which blocks most cross-site request forgery.Strictis tighter, andNoneis only allowed together withSecure.Max-Age— lifetime in seconds. Without it the cookie dies when the browser closes.
Where the store actually lives
A Map inside your process works until the process restarts, and breaks the moment a second copy of your process runs: that copy has never seen the ID. Keep sessions in Redis or a database table so every copy reads the same data.
On login
Issue a brand-new ID straight after the password check. Reusing the pre-login ID lets an attacker who planted a known ID ride into the account, which is called session fixation.
Example
When to use it
- Keeping a signed-in user signed in across page loads.
- Logging a user out everywhere at once by deleting their store rows.
- Moving an app off in-memory sessions before running a second copy of the process.
- Hardening an existing cookie against theft by a cross-site scripting bug.
More examples
express-session with a Redis store
The production shape. The store is the part that matters: swap Redis in and a restart or a second copy of the process no longer logs everybody out. Watch the units — the raw cookie attribute Max-Age is in seconds, while express-session's maxAge option is in milliseconds, and mixing them up gives sessions that expire a thousand times too soon.
const session = require('express-session');
const { RedisStore } = require('connect-redis');
const { createClient } = require('redis');
const client = createClient({ url: process.env.REDIS_URL });
await client.connect();
// This one middleware is the shorthand for the whole cycle the runnable
// example does by hand: read the Cookie header, look the ID up in the
// store, hang the data on req.session, and write Set-Cookie back.
app.use(session({
store: new RedisStore({ client }),
secret: process.env.SESSION_SECRET, // signs the cookie so the ID cannot be edited
name: 'sid', // default is 'connect.sid'
resave: false, // do not rewrite an unchanged session
saveUninitialized: false, // no store row until you put something in it
cookie: {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 1000 * 60 * 60 * 24 * 7, // MILLISECONDS here, unlike Max-Age
},
}));Login, logout, and session fixation
Both routes are callback-shaped because that is the API express-session gives. The rule behind them: a session ends on the server, not in the browser. Use this pair whenever a login form exists, and reach for regenerate again after any privilege change, such as a user becoming an admin.
app.post('/login', async (req, res) => {
const userId = await checkPassword(req.body.email, req.body.password);
if (!userId) return res.status(401).json({ error: 'invalid credentials' });
// Throw the old session ID away and mint a new one. Without this, an ID
// the attacker planted before login keeps working after it.
req.session.regenerate((err) => {
if (err) return res.status(500).json({ error: 'session error' });
req.session.userId = userId;
// save() before replying, so the store has the row before the
// browser can send its next request with the new cookie.
req.session.save((err2) => {
if (err2) return res.status(500).json({ error: 'session error' });
res.json({ ok: true });
});
});
});
app.post('/logout', (req, res) => {
// destroy() removes the server-side row. Deleting the cookie alone
// would leave a working session for anyone who copied it.
req.session.destroy(() => {
res.clearCookie('sid');
res.json({ ok: true });
});
});Deleting a cookie by hand
The reason a logout sometimes appears to do nothing. If the delete is sent with Path=/account while the cookie was set with Path=/, the browser keeps the original and the session carries on. Useful anywhere you are not using a session framework and have to clear cookies on a plain node:http response.
// A cookie is deleted by overwriting it with an expired one. The browser
// matches on name, Domain and Path, so those must be IDENTICAL to the
// attributes you set it with, or you create a second cookie instead.
res.setHeader('Set-Cookie',
'sid=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0');
// Two cookies on one response need an ARRAY. A second setHeader call
// would replace the first instead of adding to it.
res.setHeader('Set-Cookie', [
'sid=; Path=/; Max-Age=0',
'csrf=; Path=/; Max-Age=0',
]);
Discussion