HTTP is stateless: the server forgets a client the moment a response is sent. Cookies let the browser carry a small piece of state back on every request, and sessions build on that to keep richer data on the server keyed by a cookie. After this lesson you will be able to set and read cookies, sign them against tampering, store server-side sessions in memory or a database, and pick the cookie flags that keep both safe.
Express can set cookies without any package through res.cookie():
app.get("/theme/:name", (req, res) => {
res.cookie("theme", req.params.name, {
maxAge: 1000 * 60 * 60 * 24 * 30, // 30 days, in milliseconds
httpOnly: false, // readable by page JavaScript
sameSite: "lax",
});
res.redirect("/");
});
app.get("/logout-theme", (req, res) => {
res.clearCookie("theme");
res.sendStatus(204);
});The important options:
| Option | Meaning |
| --- | --- |
| maxAge / expires | Lifetime; omit both for a session cookie that dies with the browser |
| httpOnly | Hidden from document.cookie, which blocks XSS from stealing it |
| secure | Sent only over HTTPS |
| sameSite | "lax" (default-safe), "strict", or "none" (requires secure) |
| signed | Attach an HMAC so the value cannot be altered by the client |
| domain / path | Limit where the browser sends the cookie |
In Express 5, res.clearCookie() ignores maxAge and expires, so pass the same path and domain you used when setting the cookie, nothing else.
Incoming cookies arrive as one Cookie header string. cookie-parser turns it into req.cookies and verifies signed cookies into req.signedCookies:
npm install cookie-parserimport cookieParser from "cookie-parser";
app.use(cookieParser(process.env.COOKIE_SECRET));
app.get("/", (req, res) => {
const theme = req.cookies.theme ?? "light";
const userId = req.signedCookies.uid; // undefined if tampered with
res.send(`Theme: ${theme}, user: ${userId ?? "guest"}`);
});
app.post("/remember", (req, res) => {
res.cookie("uid", "42", { signed: true, httpOnly: true });
res.sendStatus(204);
});Signing does not encrypt: the value is still visible to the user, it just cannot be changed without invalidating the signature.
A cookie has a 4 KB limit and is visible to the client. Sessions keep the real data on the server and give the browser only an opaque session ID cookie (connect.sid by default).
npm install express-sessionimport session from "express-session";
app.set("trust proxy", 1); // behind Nginx, Render, Railway, etc.
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false, // do not rewrite unchanged sessions
saveUninitialized: false, // no cookie until something is stored
cookie: {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
maxAge: 1000 * 60 * 60 * 24,
},
}));
app.post("/cart", express.json(), (req, res) => {
req.session.cart ??= [];
req.session.cart.push(req.body.productId);
res.json({ items: req.session.cart.length });
});
app.post("/logout", (req, res, next) => {
req.session.destroy((err) => (err ? next(err) : res.sendStatus(204)));
});req.session is a plain object; assign to it and the middleware saves it at the end of the response. Call req.session.regenerate() right after a login to issue a fresh ID and prevent session fixation.
The default MemoryStore leaks memory and forgets everything on restart; express-session prints a warning if you use it in production. Plug in a store backed by your database:
import MongoStore from "connect-mongo";
app.use(session({
// ...same options as above
store: MongoStore.create({ mongoUrl: process.env.MONGO_URI, ttl: 60 * 60 * 24 }),
}));connect-redis works the same way for Redis, and because every app instance reads the same store, sessions survive restarts and horizontal scaling.
httpOnly for anything security-related and reserve non-httpOnly cookies for UI preferences.secure: true behind a reverse proxy only works after app.set("trust proxy", 1), otherwise Express thinks the request is plain HTTP and never sets the cookie.What does the `httpOnly` flag do?
res.cookie() and res.clearCookie() manage cookies; cookie-parser exposes them on req.cookies and req.signedCookies.httpOnly, secure and sameSite on every sensitive cookie; signing prevents tampering but does not hide values.resave: false and saveUninitialized: false.MemoryStore with connect-mongo or connect-redis before deploying.trust proxy when running behind a reverse proxy so secure cookies work.Next lesson: Connecting Express to MongoDB — connect your app to a real database with Mongoose and persist data across restarts.