Skip to content

Sessions and cookies

Leave your bag at a gym locker and you get a numbered key. The key doesn't say what's inside, isn't worth anything by itself, and is useless to anyone standing anywhere other than that locker room.

That's a session cookie. The valuable part stays with the server; the browser carries a number.

What each side holds

The server keeps a session: a small bundle of data representing one logged-in person.

js
{
  sessionId: 'a3f9c2e7b418',
  userId: 4821,
  role: 'teacher',
  expires: '2026-09-05T14:30:00Z',
}

That's the locker. It never leaves the server.

The browser gets a cookie carrying the session id and nothing else:

js
res.cookie('sid', 'a3f9c2e7b418', { maxAge: 1800000 })

That's the key. The browser stores it without being asked and sends it automatically on every request to that domain, so staying logged in requires no code on your side.

JunoWhat each side holds The automatic part surprises people. You don't write anything to attach the cookie to the next request; the browser does it because that's what browsers do with cookies.

Which is convenient, and also why a cookie sent to the wrong place is such a problem. It goes everywhere that domain goes.

JunoWhat each side holds In an Express app you rarely build either object by hand. express-session creates the session, stores it, sets the cookie, and hands you req.session to read and write.

Which is exactly why the mistakes below still happen. The framework decides where sessions live and how cookies are set; you decide what goes into them and how long they last.

JunoWhat each side holds The session id has one requirement that gets missed: it must come from a cryptographically secure random source. crypto.randomBytes qualifies, Math.random does not, and an id anyone can predict is an account anyone can occupy without a password.

The cookie also needs three attributes the framework will not choose for you. HttpOnly keeps JavaScript from reading it, so an XSS payload cannot steal the session.

Secure keeps it off plain HTTP. SameSite controls whether it rides along on cross-site requests, which is the defence against cross-site request forgery: another site making a request your browser attaches the cookie to.

Do not assume a default on that last one. Chromium treats an unset SameSite as Lax; Firefox does not, and Mozilla's bug 1617609 is resolved as WONTFIX with "when no SameSite attribute is set we use None by default". Safari's protection comes from third-party cookie blocking instead. Set it explicitly, and remember SameSite=None requires Secure.

The login round trip

Six steps, and only the first two involve a password:

  1. The user submits a username and password.
  2. The server checks them against a stored user.
  3. On success, the server creates a session holding the user id and anything else it needs, such as a role.
  4. The server sends back a cookie containing the session id.
  5. The browser stores it, then attaches it to every later request to that domain.
  6. The server reads the id, looks up the session, and knows who is asking.

Step six repeats for the life of the session. That lookup is the model's whole character: the server is consulted every time, so it can change its mind at any moment.

JunoThe login round trip The password appears once, in steps one and two, and then never again.

Everything after that is the session id doing the work, so protecting the id matters as much as protecting the password did.

JunoThe login round trip Step three is where authorization data usually sneaks in. Storing a role in the session saves a database read on every request, and it means the app is deciding permissions from a copy that stopped being accurate the moment someone changed it.

Fine for a role that rarely changes; not fine for one that gets revoked during incidents. That choice belongs to you, not to the framework.

JunoThe login round trip There's a missing step between two and three, and leaving it out is a named vulnerability. If the browser already had a session id before logging in, and the server attaches the new login to that same id, an attacker who planted the id earlier now holds an authenticated session.

That's session fixation, and the fix is to regenerate the id at the moment privilege changes: on login, and again on anything like an elevation to admin. In Express that's req.session.regenerate(), and the old session must be destroyed rather than abandoned.

Putting real data in it. The tempting version looks helpful:

Vulnerable
js
res.cookie('session', {
  sessionId: 'a3f9c2e7b418',
  userId: 4821,
  role: 'admin',
  email: '[email protected]',
  password: 'hunter2',
  ip: '203.0.113.42',
})

Everything past sessionId is a liability. Cookies get intercepted, written to logs, synced between devices and read by any JavaScript running on the page. Private details now travel everywhere the user goes, and a leaked role or id is enough to help someone impersonate them.

In STRIDE that's information disclosure and spoofing; in OWASP terms, broken access control.

Fixed
js
res.cookie('sid', 'a3f9c2e7b418', { maxAge: 1800000 })

Letting it live forever. A cookie with no maxAge or expiry is kept by many browsers indefinitely:

Vulnerable
js
res.cookie('sid', 'a3f9c2e7b418')

A stolen cookie is then useful for as long as the browser keeps it, and someone whose access should have ended still has a working key. That's elevation of privilege, and an authentication failure in OWASP terms. Thirty minutes is a reasonable starting point:

Fixed
js
res.cookie('sid', 'a3f9c2e7b418', { maxAge: 1800000 })
JunoTwo ways to ruin a cookie Both fixes are one line each. Neither needs new infrastructure or a library.

The hard bit is noticing, because a cookie stuffed with useful data and a cookie that never expires both work perfectly until the day they don't.

JunoTwo ways to ruin a cookie Watch the units. Express's maxAge is milliseconds, while the Max-Age attribute in the HTTP header is seconds, so 1800 in the wrong place is thirty minutes or under two seconds depending on which one you meant.

In review, the question for any cookie is what it holds and when it dies. Two things to check, and everything else about it is detail.

JunoTwo ways to ruin a cookie A short expiry and a good user experience aren't in conflict, though the naive version makes them look that way. Rolling sessions extend the window on activity, so someone actively working stays in while an abandoned session still dies quickly.

The pairing worth knowing is an idle timeout plus an absolute one: thirty minutes of inactivity, and a hard ceiling of eight or twelve hours regardless. Without the absolute limit, a stolen cookie kept warm by an attacker's own traffic never expires at all.

Two ways to ruin a session

Overstuffing it. A session is meant to identify someone, and it accumulates:

Vulnerable
js
{
  sessionId: 'a3f9c2e7b418',
  userId: 4821,
  role: 'teacher',
  cart: [ /* 14 items */ ],
  lastFivePages: [ /* ... */ ],
  theme: 'dark',
  cachedPosts: [ /* ... */ ],
}

Sessions live in server memory, so this costs memory per logged-in user. Worse, it goes stale: the app starts making decisions from a copy of data the database has since changed. STRIDE calls that tampering, not because anyone edited it, but because the decision rests on something no longer true.

Fixed
js
{
  sessionId: 'a3f9c2e7b418',
  userId: 4821,
  role: 'teacher',
  expires: '2026-09-05T14:30:00Z',
}

Letting it live forever. The same mistake as the cookie, one layer down and worse, because now the server is the one trusting stale data. A session with no expiry means a stolen cookie works indefinitely, a revoked permission never takes effect, and logging someone out everywhere becomes impossible.

JunoTwo ways to ruin a session Both session mistakes rhyme with the cookie ones, which makes them easier to remember: keep it small, and give it an end.

The difference is that a cookie problem is visible in the browser, while a session problem sits on the server where nobody looks.

JunoTwo ways to ruin a session The rule that keeps sessions small: store identity, not state. A user id and maybe a role. Anything you could look up, look up.

A shopping cart belongs in the database, where it survives a server restart and follows the user to another device. Putting it in the session usually means losing it at the worst moment anyway.

JunoTwo ways to ruin a session Express's default session store is in-process memory, and its own documentation says plainly that it is not for production. It leaks, it dies with the process, and it does not exist for the instance next door.

The practical consequence is that a deploy logs everybody out, and behind a load balancer half your users are logged out at random. Redis is the usual answer. Watch the version: connect-redis v8 exports RedisStore as a named export where v6 and v7 differ, and that catches people upgrading.

Frameworks handle the mechanics well. Express, Django, Rails and Laravel all manage the store and set sensible cookie defaults. None of them decides what you put in the session or how long it lives, and that is exactly where all four of these mistakes live.

Try it

Four scenarios. Name the problems and fix each one.

js
// 1. Cookie set after login
res.cookie('session', {
  sessionId: 'a3f9c2e7b418',
  userId: 4821,
  password: 'hunter2',
  ip: '203.0.113.42',
}, { maxAge: 1000000000000 })

// 2. Cookie set after login
res.cookie('sid', 'a3f9c2e7b418')

// 3. A stored session
{ sessionId: 'b7d1', userId: 91, role: 'admin', cart: [], theme: 'dark',
  ip: '203.0.113.42', expires: '3000-01-01T00:00:00Z' }

// 4. A cookie and its session
res.cookie('sid', { userId: 91, role: 'admin' }, { maxAge: 2000 })
{ sessionId: 'c4e2', userId: 91, role: 'admin', expires: '2099-01-01T00:00:00Z' }
Compare your answers
#ProblemsFix
1Sensitive data in the cookie, and a maxAge of about 32 yearsKeep sessionId only, maxAge: 1800000
2No expiry at all, so many browsers keep it indefinitelyAdd maxAge
3Overstuffed, and expires in the year 3000Keep sessionId, userId, role, expiry 30 minutes out
4Cookie has no session id and carries userId and role instead; its maxAge of 2 seconds is unusable; the session expires in 2099Put the session id in the cookie, drop the rest, maxAge: 1800000, expiry 30 minutes out

Scenario 4 is the interesting one, because it fails in both directions at once. The cookie is simultaneously too short-lived to be usable and carrying data that should never leave the server, while the session behind it lasts most of a century.

If your fixes differ in the details, that's fine. Reaching for the reasoning is the part that matters.

Where this goes next

Stateful identity is sturdy because the server stays in charge. Every one of these four mistakes chips at that: data escaping the server, or a decision outliving the facts behind it.

Opaque bearer tokens starts the opposite arc, where the server stops keeping a session at all and the client carries its own proof.