express-session
HTTP is stateless, so without help the server forgets who a user is between requests. Sessions solve this by storing state on the server and handing the browser a small, signed cookie that points at it. The express-session middleware wires this up for you: it parses the session cookie, loads the matching record from a store, and exposes it as req.session so your handlers can read and mutate per-user data — a logged-in user id, a shopping cart, a CSRF token — without putting any of it in the cookie itself.
Installing and configuring
Install the package and mount it as middleware. The only required option is secret, which signs the session id cookie so clients cannot forge or tamper with it. The two boolean flags below — resave and saveUninitialized — should almost always be set explicitly, because their defaults are deprecated and noisy.
npm install express-session
const express = require("express");
const session = require("express-session");
const app = express();
app.use(
session({
secret: process.env.SESSION_SECRET, // sign the session id cookie
resave: false, // don't re-save unchanged sessions
saveUninitialized: false, // don't store empty sessions
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 1000 * 60 * 60 * 24, // 1 day
},
})
);
app.listen(3000, () => console.log("Listening on http://localhost:3000"));
With saveUninitialized: false, a session record is only persisted once you actually write something to req.session, which keeps your store free of empty rows for bots and anonymous visitors.
Never hard-code the
secret. Read it from an environment variable, and rotate it by passing an array (secret: [newSecret, oldSecret]) so existing cookies signed with the old value still verify while new ones use the first entry.
Reading and mutating req.session
Inside any handler, req.session is a plain object you can read from and assign to. Anything you set is automatically saved back to the store at the end of the request. Two properties are reserved: req.session.id (the session id) and req.session.cookie (the per-session cookie settings).
app.use(express.json());
// Log in: write data onto the session
app.post("/login", async (req, res) => {
const user = await authenticate(req.body.email, req.body.password);
if (!user) return res.status(401).json({ error: "Invalid credentials" });
req.session.userId = user.id;
req.session.role = user.role;
res.json({ ok: true });
});
// Read it back on a protected route
app.get("/me", (req, res) => {
if (!req.session.userId) return res.status(401).json({ error: "Not logged in" });
res.json({ userId: req.session.userId, role: req.session.role });
});
To end a session, call req.session.destroy, which removes it from the store and clears the cookie.
app.post("/logout", (req, res) => {
req.session.destroy((err) => {
if (err) return res.status(500).json({ error: "Could not log out" });
res.clearCookie("connect.sid");
res.json({ ok: true });
});
});
A successful login response sets the signed cookie:
Output:
< HTTP/1.1 200 OK
< Set-Cookie: connect.sid=s%3AaT9k...; Path=/; HttpOnly; SameSite=Lax
< Content-Type: application/json
{"ok":true}
Cookie options
The cookie object controls how the session id is stored in the browser. Getting these right is what separates a secure session from a hijackable one.
| Option | Type | Purpose |
|---|---|---|
httpOnly | boolean | Hide the cookie from client JavaScript (default true) |
secure | boolean | Only send over HTTPS — required in production |
sameSite | "lax" / "strict" / "none" | CSRF protection; "none" needs secure: true |
maxAge | number | Lifetime in milliseconds before the cookie expires |
domain | string | Scope the cookie to a domain and its subdomains |
path | string | URL path the cookie applies to (default /) |
When you run behind a reverse proxy or load balancer that terminates TLS (Nginx, Heroku, AWS ALB), set app.set("trust proxy", 1) so Express recognizes the original https scheme and the secure cookie is actually sent.
Session stores
The default store is MemoryStore, which keeps sessions in process memory. It is fine for local development but leaks memory, does not survive restarts, and cannot be shared across multiple instances — express-session will warn you about this on boot. For production, use a shared external store such as Redis or MongoDB.
npm install connect-redis redis
const session = require("express-session");
const { RedisStore } = require("connect-redis");
const { createClient } = require("redis");
const redisClient = createClient({ url: process.env.REDIS_URL });
redisClient.connect().catch(console.error);
app.use(
session({
store: new RedisStore({ client: redisClient, prefix: "sess:" }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: { httpOnly: true, secure: true, maxAge: 1000 * 60 * 60 },
})
);
For MongoDB, connect-mongo plugs in the same way and reuses an existing connection string:
const MongoStore = require("connect-mongo");
app.use(
session({
store: MongoStore.create({
mongoUrl: process.env.MONGO_URL,
ttl: 60 * 60 * 24, // expire after 1 day (seconds)
}),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
})
);
A shared store means any instance behind your load balancer can serve any user’s request, and sessions persist across deploys and restarts.
Best Practices
- Always set
httpOnly: true,secure: true(in production), andsameSiteto defend against XSS theft and CSRF. - Use a persistent store (Redis or Mongo) in production — never ship the default
MemoryStore. - Keep
resave: falseandsaveUninitialized: falseto avoid race conditions and useless writes. - Load
secretfrom the environment and support rotation by passing an array of secrets. - Call
req.session.regenerateright after login to issue a fresh session id and prevent session fixation. - Set
app.set("trust proxy", 1)when terminating TLS at a proxy so secure cookies are sent correctly. - Pick a
maxAge(and storettl) that balances convenience against exposure, and callreq.session.destroyon logout.