NodeJSSessions & Cookies

Sessions & Cookies

Once a user logs in, how does the next request know it's still them? The oldest, most battle-tested answer is the server-side session: the server keeps a record of who's logged in and hands the browser an opaque cookie that points to it. The cookie travels automatically on every request; the server looks up the session and recovers the user. This page covers how sessions work, the all-important secure cookie flags, where to store sessions, and how this model compares to stateless JWTs.

How a session works

Text
LOGIN
  client → POST /login (credentials)
  server  verifies, creates session { userId: 42 }, stores it,
          sends Set-Cookie: sid=<random-id>; HttpOnly; Secure
          ↑ the cookie holds only an opaque ID, NOT the user data

EVERY LATER REQUEST
  client → GET /profile   Cookie: sid=<random-id>   (sent automatically)
  server  looks up session by sid → { userId: 42 } → loads user → req.user
The cookie is just a pointer; the real data lives on the server
In the classic session model, the cookie contains nothing but a **random, unguessable session ID**. All the actual state — who the user is, their roles — sits in server-side storage keyed by that ID. This is why sessions are easy to **revoke**: delete the server record and the cookie instantly becomes meaningless, even though the browser still holds it. The trade-off is that the server must store and look up session state on every request — the thing [JWTs](/nodejs/jwt) try to avoid.
express-session

Bash
npm install express-session connect-redis redis

JS
import session from 'express-session'
import { RedisStore } from 'connect-redis'
import { createClient } from 'redis'

const redis = createClient({ url: process.env.REDIS_URL })
await redis.connect()

app.use(session({
  store: new RedisStore({ client: redis }),     // NOT the default MemoryStore
  secret: process.env.SESSION_SECRET,           // signs the cookie
  resave: false,
  saveUninitialized: false,                     // don't store empty sessions
  cookie: {
    httpOnly: true,                             // JS can't read it → blocks XSS theft
    secure: process.env.NODE_ENV === 'production', // HTTPS-only
    sameSite: 'lax',                            // CSRF mitigation
    maxAge: 1000 * 60 * 60 * 24,                // 24h
  },
}))

// Log in: store the user id on the session
app.post('/login', async (req, res) => {
  /* ...verify password... */
  req.session.userId = user.id                  // express-session persists it + sets cookie
  res.json({ ok: true })
})

// Log out: destroy the server-side session
app.post('/logout', (req, res) => {
  req.session.destroy(() => res.json({ ok: true }))
})
The secure cookie flags — get these right

Flag

What it does

Why it matters

HttpOnly

Hides cookie from document.cookie

An XSS script can’t steal the session

Secure

Sent only over HTTPS

Prevents interception on plain HTTP

SameSite

Limits cross-site sending

Mitigates CSRF

maxAge / Expires

Sets cookie lifetime

Bounds how long a stolen cookie lives

Domain / Path

Scopes where it’s sent

Least privilege — don’t over-share

`HttpOnly` + `Secure` + `SameSite` are mandatory for session cookies
A session cookie is a bearer credential — whoever holds it *is* the user. Three flags are non-negotiable: **`HttpOnly`** so client-side JavaScript (and thus an XSS payload) can't read it; **`Secure`** so it never travels over unencrypted HTTP; and **`SameSite`** (`lax` or `strict`) so other sites can't make the browser send it along on forged requests. Omitting any one opens a concrete attack — session theft via XSS, interception over HTTP, or CSRF.
Never use the default in-memory store
`express-session`'s default MemoryStore leaks memory and breaks with multiple instances
Out of the box, `express-session` warns you: its default store keeps sessions in process memory. That means sessions **vanish on restart**, **leak memory** under load (it never cleans up), and **don't work across instances** — a request routed to a different server has no session. Use a shared store ([Redis](/nodejs/redis) via `connect-redis`, or a database) in any real deployment so sessions survive restarts and are visible to every instance behind your load balancer.
Session security beyond the flags
  • Regenerate the session ID on login (req.session.regenerate) to prevent session fixation.

  • Destroy the session on logout and on password change — don’t just clear the cookie.

  • Set an idle timeout and an absolute timeout so abandoned sessions expire.

  • Use a long, random secret from the environment; rotate it carefully.

  • Store as little as possible in the session — just an id; load fresh user data per request.

Regenerate the session ID at login to stop fixation attacks
In a **session fixation** attack, the attacker plants a known session ID in the victim's browser *before* they log in; if the server reuses that ID after authentication, the attacker now shares the victim's logged-in session. The fix is to call `req.session.regenerate()` (or equivalent) at the moment of login so the ID changes the instant privileges rise. Likewise, fully **destroy** the server-side session on logout — clearing only the cookie leaves the record valid for anyone who copied it.
Sessions vs tokens

Server-side session

Stateless JWT

State

Stored on server

Self-contained in the token

Revocation

Easy — delete the record

Hard — valid until expiry

Scaling

Needs shared store (Redis)

No lookup needed

Best fit

Web apps, first-party

APIs, mobile, microservices

Sessions excel at revocation; tokens excel at statelessness
Neither model is universally better. **Sessions** shine when you need instant revocation and a server-rendered web app talking to its own backend — logout actually invalidates access. **[JWTs](/nodejs/jwt)** shine when you want stateless scaling and cross-service auth, at the cost of harder revocation (you can't un-issue a token before it expires without extra machinery like a denylist or [refresh tokens](/nodejs/refresh-tokens)). Many real systems combine them: short-lived tokens backed by a session-tracked refresh token.
Next
The stateless alternative every API reaches for: [JSON Web Tokens](/nodejs/jwt).