Learn / Security / Lesson 03
Sessions and cookies
What a session cookie must carry, the flags that protect it, and the trade-off between signed cookies and server-side sessions.
Last updated: 2026-09-19
What it is
HTTP forgets you between requests. A session is how the server remembers that this browser already signed in. The browser holds a cookie; the server trusts whatever that cookie proves. So whoever holds the cookie is the user, as far as the server can tell.
Why it is a rule
- Firesheep (2010). Session cookies sent over plain HTTP were read off shared Wi-Fi and replayed. No password was ever needed. The fix was the
Secureflag and HTTPS everywhere. - The Samy worm (2005). A script injected into a MySpace profile ran in the browser of everyone who viewed it and made them add Samy as a friend and copy the script to their own profile. It reached over a million profiles in under a day. Any script running in your page can act as the user;
HttpOnlyat least stops it from carrying the cookie away to use later from somewhere else.
How to do it
The flags
Set-Cookie: session=<value>; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=28800| Flag | What it stops |
|---|---|
HttpOnly | JavaScript cannot read the cookie, so an injected script cannot steal it |
Secure | The cookie is never sent over plain HTTP |
SameSite=Lax | Other sites cannot send it with a POST (see the CSRF lesson) |
Max-Age | The session ends on its own, even if nobody logs out |
A cookie name starting with __Host- is refused by the browser unless it is Secure, has Path=/ and no Domain, so a subdomain cannot overwrite it.
What goes inside
The value must be impossible to guess or forge. Two common shapes:
- A random id (128 bits or more) that points to a row on the server.
- A signed value: the data, an expiry, and an HMAC over both with a server secret.
const sign = (body, exp) =>
crypto.createHmac('sha256', SECRET).update(`${body}.${exp}`).digest('base64url')
const valid = (cookie) => {
const [body, exp, sig] = cookie.split('.')
if (!sig || Date.now() > Number(exp)) return false
const want = Buffer.from(sign(body, exp))
const got = Buffer.from(sig)
return want.length === got.length && crypto.timingSafeEqual(want, got)
}timingSafeEqual matters: a normal === stops at the first wrong character, and the time it takes can leak how much of a guess was right.
The life of a session
- Issue a new session at login. Reusing one from before login allows session fixation.
- Clear it at logout with the same flags it was set with. A mismatched
SameSiteorSecureon the delete leaves the cookie in place and logout silently does nothing. - Re-check the user's role and access on every request. A cookie proves who, not what they may do today.
How we do it here
Every session cookie is HttpOnly, SameSite=Lax, and Secure in production, is signed with HMAC-SHA256 and carries its own expiry. Signatures are compared in constant time. Access is checked on every request, not only at sign-in.
Benefits
- Signed cookies need no session table: nothing to store, look up or clean.
- The flags cost nothing and remove whole attack classes.
- A short
Max-Agelimits the damage of a stolen cookie.
Disadvantages
- A signed cookie cannot be revoked early. It stays valid until it expires, unless you add a server-side deny list or rotate the secret, which logs everyone out.
- Server-side sessions need storage and a lookup on every request, and they must survive restarts.
- Short sessions are safer but make people sign in more often.
SameSite=Strictis safer again but drops the session when a user follows a link to you from an email.
Checklist
HttpOnly,Secure,SameSiteon every session cookie.- Values are random or signed, and compared in constant time.
- A new session at login; cleared with matching flags at logout.
- Permissions re-checked per request.