Sessions and Cookies

HTTP is stateless: every request is independent, so a server has no built-in way to remember that two requests came from the same browser. Cookies solve this by letting the server hand the browser a small piece of data that it automatically sends back on every future request. Sessions build on cookies: instead of storing real data in the cookie itself, the server stores a random session ID in the cookie and keeps the actual data (like "this user is logged in as alice") on the server. Getting this wrong is one of the most common ways Node.js applications get compromised, so this lesson covers both the mechanics and the security rules.

Overview: How Cookies and Sessions Work

A cookie is just an HTTP header. When your server wants to set one, it adds a Set-Cookie header to the response:

Set-Cookie: visits=3; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=3600

The browser stores that cookie and, on every subsequent request to a matching URL, sends it back in a plain Cookie request header: Cookie: visits=3. Node does none of this parsing for you at the core level — node:http gives you the raw header strings, and it is your job (or a library's job) to parse and format them.

A cookie by itself is just client-side storage: the browser holds the value, and a user (or malicious script) can read or edit it. A session flips that around for anything sensitive: the cookie holds only an unguessable, random session ID, and the real data (user ID, permissions, cart contents) lives in a server-side store — memory, Redis, or a database. On each request, the server looks up the ID from the cookie in that store to find out who is asking. This is why sessions are the standard way to implement "logged in" state: the client never sees or controls the actual data, only an opaque token.

Under the hood, Node's HTTP server does not treat cookies specially at all — they are ordinary headers. Frameworks like Express add cookie support through middleware (cookie-parser for reading/writing individual cookies, express-session for full session management with a pluggable store). Understanding the raw header first makes the middleware much less mysterious.

Syntax

The general form of a Set-Cookie header is:

Set-Cookie: name=value; Attribute1; Attribute2=value2; ...
Attribute Purpose
HttpOnly Blocks client-side JavaScript (document.cookie) from reading the cookie. Essential for session cookies — stops XSS from stealing them.
Secure Cookie is only sent over HTTPS. Must be set for anything sensitive in production.
SameSite Strict, Lax, or None. Controls whether the cookie is sent on cross-site requests; the main built-in defense against CSRF.
Path URL path prefix the cookie applies to (default is the current path).
Domain Which host(s) the cookie is sent to. Omit it to scope the cookie to the exact host that set it.
Max-Age / Expires Lifetime in seconds, or an absolute date. Without either, it's a session cookie that dies when the browser closes.

Examples

Example 1: Raw cookies with node:http

This example parses the incoming Cookie header by hand and writes a Set-Cookie header back, with no libraries at all.

import http from "node:http";

function parseCookies(header) {
  const cookies = {};
  if (!header) return cookies;
  for (const pair of header.split(";")) {
    const idx = pair.indexOf("=");
    if (idx === -1) continue;
    const key = pair.slice(0, idx).trim();
    const value = pair.slice(idx + 1).trim();
    cookies[key] = decodeURIComponent(value);
  }
  return cookies;
}

const server = http.createServer((req, res) => {
  const cookies = parseCookies(req.headers.cookie);
  const visits = cookies.visits ? Number(cookies.visits) + 1 : 1;

  res.setHeader(
    "Set-Cookie",
    `visits=${visits}; HttpOnly; SameSite=Strict; Path=/; Max-Age=3600`
  );
  res.writeHead(200, { "Content-Type": "text/plain" });
  res.end(`You have visited ${cookies.visits ?? 0} time(s) before.\n`);
});

server.listen(3000, () => {
  console.log("Server running at http://localhost:3000");
});

Output (first request):

You have visited 0 time(s) before.

Reload the page and the count increases, because the browser sent back the visits cookie the server set last time. Note this example omits Secure since it's testing over plain HTTP on localhost — a browser will silently refuse to store a Secure cookie unless the connection is actually HTTPS, which is a common source of "why isn't my cookie being set?" confusion in local development.

Example 2: Signed cookies with Express and cookie-parser

Plain cookies can be edited by the client. A signed cookie adds an HMAC signature so the server can detect tampering (it does not encrypt the value — the client can still read it, just not modify it undetected).

npm install express cookie-parser
import express from "express";
import cookieParser from "cookie-parser";

const app = express();
const COOKIE_SECRET = process.env.COOKIE_SECRET ?? "dev-secret-change-me";

app.use(cookieParser(COOKIE_SECRET));

app.get("/set-theme/:theme", (req, res) => {
  res.cookie("theme", req.params.theme, {
    httpOnly: false,
    secure: true,
    sameSite: "lax",
    maxAge: 30 * 24 * 60 * 60 * 1000,
    signed: true,
  });
  res.json({ theme: req.params.theme });
});

app.get("/theme", (req, res) => {
  const theme = req.signedCookies.theme ?? "light";
  res.json({ theme });
});

app.listen(3000, () => {
  console.log("Server running at http://localhost:3000");
});

Output (GET /set-theme/dark):

{"theme":"dark"}

cookieParser(COOKIE_SECRET) both signs outgoing cookies (when you pass signed: true) and verifies incoming ones, exposing valid ones on req.signedCookies and putting tampered ones in req.cookies with a value of false instead. This theme preference is not sensitive, so httpOnly: false is fine here (a settings widget written in client JS may need to read it) — but note that anything actually security-relevant should never be readable by JavaScript at all.

Example 3: A real login session with express-session

For authentication, don't hand-roll cookie logic — use express-session, which generates a random session ID, stores your data server-side, and signs the ID cookie for you.

npm install express express-session
import express from "express";
import session from "express-session";

const app = express();
app.use(express.json());

app.use(
  session({
    name: "sid",
    secret: process.env.SESSION_SECRET ?? "dev-secret-change-me",
    resave: false,
    saveUninitialized: false,
    cookie: {
      httpOnly: true,
      secure: true,
      sameSite: "lax",
      maxAge: 15 * 60 * 1000,
    },
  })
);

const users = { alice: "hunter2" };

app.post("/login", (req, res) => {
  const { username, password } = req.body;
  if (users[username] !== password) {
    return res.status(401).json({ error: "Invalid credentials" });
  }

  req.session.regenerate((err) => {
    if (err) return res.status(500).json({ error: "Session error" });
    req.session.userId = username;
    res.json({ message: `Welcome, ${username}` });
  });
});

app.get("/me", (req, res) => {
  if (!req.session.userId) {
    return res.status(401).json({ error: "Not logged in" });
  }
  res.json({ userId: req.session.userId });
});

app.post("/logout", (req, res) => {
  req.session.destroy((err) => {
    if (err) return res.status(500).json({ error: "Logout failed" });
    res.clearCookie("sid");
    res.json({ message: "Logged out" });
  });
});

app.listen(3000, () => {
  console.log("Server running at http://localhost:3000");
});

Output (POST /login with correct credentials):

{"message":"Welcome, alice"}

By default express-session uses an in-memory store, which is fine for this local demo but explicitly unsuitable for production (see Common Mistakes). In a real deployment, swap in connect-redis, connect-mongo, or connect-pg-simple as the store option so sessions survive restarts and are shared across multiple server processes.

How It Works Step by Step

  1. Browser sends POST /login with credentials, no session cookie yet (or an anonymous one).
  2. express-session middleware runs first, sees no valid session, and attaches an empty req.session object.
  3. Your handler verifies the password, then calls req.session.regenerate(), which discards any old session ID and store entry and creates a brand-new one — this is what prevents session fixation (see below).
  4. req.session.userId = username writes into the new session object; when the response finishes, the middleware serializes that object into the store (memory/Redis/DB) keyed by the new session ID.
  5. The response carries Set-Cookie: sid=<signed session id>; HttpOnly; Secure; SameSite=Lax.
  6. On the next request (e.g. GET /me), the browser automatically sends Cookie: sid=.... The middleware verifies the signature, loads the matching record from the store, and populates req.session before your route handler runs.
  7. On POST /logout, req.session.destroy() removes the record from the store, and res.clearCookie() tells the browser to drop the cookie.

Common Mistakes

1. Forgetting to regenerate the session ID on login (session fixation)

app.post("/login", (req, res) => {
  const { username, password } = req.body;
  if (users[username] !== password) {
    return res.status(401).json({ error: "Invalid credentials" });
  }
  req.session.userId = username; // BUG: reuses whatever session ID the client already had
  res.json({ message: `Welcome, ${username}` });
});

If an attacker can get a victim to use a session ID the attacker already knows (via a crafted link or a shared kiosk), reusing that same ID after login lets the attacker hijack the now-authenticated session. Always call req.session.regenerate() right after a successful login, as shown in Example 3.

2. Making the session cookie readable by JavaScript

res.cookie("sid", sessionId, { httpOnly: false }); // BUG: any injected script can read document.cookie and steal it

If the site has any XSS vulnerability, a non-HttpOnly session cookie can be exfiltrated with a single line of injected JavaScript. Session and auth cookies must always set httpOnly: true.

3. Trusting unsigned cookie data

res.setHeader("Set-Cookie", `role=admin; Path=/`); // BUG: client can edit this cookie in devtools to grant themselves admin

Never store authorization decisions directly in a plain cookie value. Store only a session ID and look up the role server-side, or at minimum use a signed cookie so tampering is detectable — but authorization data still belongs in the server-side session store, not the cookie itself.

4. Shipping the default MemoryStore to production

express-session prints a console warning for a reason: MemoryStore is not designed for a production environment. It leaks memory over time, loses every session on restart, and cannot be shared across multiple Node processes or servers — so a user load-balanced to a different instance appears logged out. Always configure a real store (Redis, MongoDB, Postgres) before deploying.

Best Practices

  • Always set HttpOnly and Secure on session/auth cookies, and choose SameSite: "lax" or "strict" as your first line of CSRF defense.
  • Call req.session.regenerate() after every privilege change (login, password change, role change), not just at login.
  • Use a production-grade session store (connect-redis, connect-mongo, etc.) and never the default MemoryStore outside local development.
  • Keep SESSION_SECRET / COOKIE_SECRET out of source control — load them from environment variables, never hardcode them.
  • Set a reasonably short maxAge on session cookies and re-issue on activity rather than making them last indefinitely.
  • Set saveUninitialized: false and resave: false to avoid creating empty sessions for anonymous visitors and unnecessary store writes.
  • Always destroy the session server-side on logout (req.session.destroy()), not just clear the cookie client-side — otherwise the session stays valid if the cookie is replayed.

Practice Exercises

  • Extend Example 3 so that /login rate-limits repeated failed attempts per IP address, storing the failure count in a plain (non-session) cookie or an in-memory map.
  • Add a /change-password route to Example 3 that requires the user to be logged in, and make sure it calls req.session.regenerate() after a successful password change.
  • Swap Example 3's default store for connect-redis (you'll need a running Redis instance) and verify sessions survive a server restart.

Summary

  • Cookies are ordinary HTTP headers (Set-Cookie / Cookie) that the browser stores and replays automatically; Node's core http module does not parse them for you.
  • Sessions store a random ID in a cookie and keep the real data server-side, in a pluggable store, so the client never controls sensitive state.
  • HttpOnly, Secure, and SameSite are the three cookie attributes that matter most for security.
  • Always regenerate the session ID on login to prevent session fixation, and destroy it server-side on logout.
  • Never use the default MemoryStore in production — use Redis, MongoDB, or Postgres via the matching connect-* package.