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
- Browser sends
POST /loginwith credentials, no session cookie yet (or an anonymous one). express-sessionmiddleware runs first, sees no valid session, and attaches an emptyreq.sessionobject.- 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). req.session.userId = usernamewrites 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.- The response carries
Set-Cookie: sid=<signed session id>; HttpOnly; Secure; SameSite=Lax. - On the next request (e.g.
GET /me), the browser automatically sendsCookie: sid=.... The middleware verifies the signature, loads the matching record from the store, and populatesreq.sessionbefore your route handler runs. - On
POST /logout,req.session.destroy()removes the record from the store, andres.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
HttpOnlyandSecureon session/auth cookies, and chooseSameSite: "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 defaultMemoryStoreoutside local development. - Keep
SESSION_SECRET/COOKIE_SECRETout of source control — load them from environment variables, never hardcode them. - Set a reasonably short
maxAgeon session cookies and re-issue on activity rather than making them last indefinitely. - Set
saveUninitialized: falseandresave: falseto 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
/loginrate-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-passwordroute to Example 3 that requires the user to be logged in, and make sure it callsreq.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 corehttpmodule 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, andSameSiteare 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
MemoryStorein production — use Redis, MongoDB, or Postgres via the matchingconnect-*package.
