What a session has to do
- Identify the user on every request, without asking for the password again.
- Expire — after inactivity, and absolutely, whatever happens.
- Rotate at every change of privilege, especially at login.
- Be revocable — a logout, a password change, a stolen laptop.
- Be unguessable — at least 128 bits from a cryptographic random source, never a counter, never a hash of the email.
Session fixation
An attacker sets a session id in the victim's browser before they log in — through a link with a session parameter, a subdomain they control, or an XSS. If the id survives the login, the attacker's session is now an authenticated one.
Rotating at login costs one line. Rotate again at any elevation: becoming an admin, entering a payment flow, confirming a second factor.
// wrong: the id the visitor arrived with is still the id after login
req.session.userId = user.id;
// right: a brand-new session, and the old one destroyed
await req.session.regenerate();
req.session.userId = user.id;
Two clocks
Idle timeout protects the shared computer. Absolute timeout bounds how long a stolen session is useful, and stops a session living for months because a tab keeps polling. Pick the numbers from what the account can do: a banking session is minutes, a blog reader is weeks.
const IDLE = 30 * 60 * 1000; // 30 minutes without a request
const ABSOLUTE = 12 * 60 * 60 * 1000; // 12 hours, however active you are
const valid = (session, now) =>
now - session.lastSeen < IDLE &&
now - session.createdAt < ABSOLUTE;
Server-side sessions, and why revocation is the deciding factor
The industry spent a decade putting JWTs in cookies to avoid a lookup that a Redis hit answers in under a millisecond, and then re-invented revocation with deny lists. For an ordinary web application, a session id in an HttpOnly cookie with a server-side store is the simpler and stronger default.
| Session id + server store | Self-contained token (JWT) | |
|---|---|---|
| Lookup per request | yes — a cache hit | none |
| Revoke now | delete the row | not possible without a deny list |
| Change a role immediately | yes | only at the next refresh |
| Size | ~32 bytes | hundreds of bytes, on every request |
| Good for | a normal web application | service-to-service, short-lived access tokens |
If you do use tokens
Short-lived access token (minutes) plus a long-lived refresh token in an HttpOnly, path-scoped cookie — rotated on every use, so a stolen refresh token is detectable when both copies are used. That is the pattern worth copying; everything simpler than it is a password that lasts a month.
// 1. trusting the header
const { alg } = JSON.parse(atob(token.split(".")[0])); // "none" — and libraries used to accept it
// fix: pin the algorithm yourself, and never read it from the token
// 2. not checking exp — a token that never expires is a password
// 3. keeping it in localStorage — see the storage lesson
// 4. putting anything secret in the payload: it is base64, not encryption
// 5. a 30-day access token "so users do not get logged out"
The cookie itself
Set-Cookie: sid=<128 random bits>; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=43200