Cookies & Sessions
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Express 5.3 (npm express 5.3.0, published 2026-10-09) on Node.js 18 or later, the minimum
for Express 5, and it states where Express 4 (latest 4.22.3) behaves differently. The middleware versions are
cookie-parser 1.4.7, cookie-session 2.1.1, express-session 1.19.0, connect-redis 10.0.0 and connect-mongo 6.0.0.
Everything was checked against the official documentation and the package repositories on 2026-10-29. Cookies are the
only state a browser sends back on its own, so they carry sessions, remember-me tokens and refresh tokens, and they are
also where most web-application security mistakes are made. The page goes from the lowest level, the Set-Cookie
header written by res.cookie(), up to server-side sessions with a production store, and finishes with the trust proxy
setting that decides whether a secure cookie is sent at all behind a load balancer. The examples use ESM and the node:
prefix for built-in modules and contain placeholders only: secrets always come from process.env.SESSION_SECRET, never
from the source. The running scenario is the Bookshelf API that grows across this section; the authentication
mechanics built on top of sessions (password hashing, Passport, JWT) are in
Authentication & Authorization.
Cookie basics
A cookie is a name/value pair that the server sends in a Set-Cookie response header and that the browser returns in a
Cookie request header on every later request that matches the cookie’s scope. Three properties of the mechanism drive
every design decision in this page:
-
The browser, not the server, decides when to send a cookie, and it sends it on every matching request, including requests triggered by other sites. This is the root of cross-site request forgery.
-
The server never sees the cookie attributes again. Only
name=valuepairs come back, so the server cannot tell whether a cookie washttpOnly,secureor expired. -
Any value the client returns is untrusted input. Without a signature or a server-side lookup, a cookie is just a string the user can edit in the developer tools.
Express ships only the writing half. res.cookie() and res.clearCookie() are part of the core, and req.cookies is
not: reading cookies needs the cookie-parser middleware (below), and req.get('Cookie') returns the raw header.
Cookies are described in RFC 6265, whose successor draft (RFC 6265bis) adds SameSite, the __Host- and __Secure-
prefixes and the cookie-size rules that browsers already implement.
res.cookie() and its options
res.cookie(name, value [, options]) sets one Set-Cookie header. Calling it several times adds several headers. The
value may be a string or an object; an object is serialised with JSON.stringify and stored with the prefix j:
(cookie-parser reverses this when reading). The options below are the ones Express 5.3 documents:
| Option | Default | Meaning |
|---|---|---|
|
none |
Domain name for the cookie. When omitted the cookie is a host-only cookie and is not sent to subdomains. Setting it widens the scope to every subdomain, which is rarely what an application wants. |
|
|
Synchronous function used to encode the value. Use |
|
none (session cookie) |
A |
|
|
Hides the cookie from |
|
none |
Lifetime in milliseconds, converted to |
|
|
Path prefix of the URLs that receive the cookie. It is not a security boundary, because scripts on the same origin can read each other’s paths. |
|
|
Adds the |
|
none |
|
|
|
Marks the cookie as HTTPS-only. Browsers refuse to send it over plain HTTP, and (except on localhost) refuse to store it when it arrives over plain HTTP. |
|
|
Signs the value with the secret given to |
|
none |
|
SameSite
SameSite tells the browser whether to attach the cookie to requests that start on another site (the registrable
domain, so a.example.com and b.example.com are the same site, unlike the same origin):
-
'strict'sends the cookie only for requests that originate on the same site. A user who clicks a link to the application from an email arrives without the cookie, so the first page looks logged out. -
'lax'also sends it on top-level navigations that use a safe method (GET), such as clicking a link. It is the right default for session cookies and, together with checking theOriginheader, blocks most cross-site request forgery. Chromium-based browsers treat a cookie without the attribute asLax, but the application should say so explicitly. -
'none'sends it on every cross-site request. It is mandatory for an embedded widget or a cross-origin API that is authenticated with a cookie, and browsers reject it unless the cookie is alsosecure. A cross-origin front end also needs the CORScredentialsconfiguration described in CORS.
Cookie name prefixes
Browsers enforce two name prefixes. A cookie called __Secure-sid must be set with secure. A cookie called
__Host-sid must additionally have path: '/' and no domain, which makes it host-only and prevents a sibling
subdomain from overwriting it. Production session cookies should use __Host- whenever the application is served from
one host over HTTPS. The prefix is part of the cookie name, so it only works when the name option of the session
middleware is changed accordingly.
res.clearCookie()
res.clearCookie(name [, options]) sends a Set-Cookie header that expires the cookie. A browser deletes a cookie only
when the new header matches the old one on name, domain and path, so the options must repeat the ones used when
the cookie was set. Two changes matter for old code:
-
In Express 5,
res.clearCookie()ignoresmaxAgeandexpires: it always sends an epochExpires. In Express 4 amaxAgepassed toclearCookieoverrode the expiry and the cookie was not deleted, which tutorials worked around by omitting it. Passing the same options object that was used forres.cookie()is now safe. -
Clearing does not remove anything on the server. For a session that means also destroying the session record (see Logging out).
Setting, reading and clearing a cookie
The example is the smallest complete round trip: remembering the reader’s preferred language in a plain cookie that
carries no authority. Reading needs cookie-parser, covered in the next section; the routes are the same with or without
a secret.
// preferences.js: node --watch --env-file=.env preferences.js
import express from 'express'
import cookieParser from 'cookie-parser'
const app = express()
const oneYear = 365 * 24 * 60 * 60 * 1000 // milliseconds
// The options must be identical when setting and clearing.
const preferenceCookie = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
}
app.use(cookieParser())
app.get('/preferences', (req, res) => {
// req.cookies is undefined unless cookie-parser ran first.
res.json({ language: req.cookies.language ?? 'en' })
})
app.post('/preferences/:language', (req, res) => {
const { language } = req.params
if (!['en', 'es', 'fr'].includes(language)) {
return res.status(400).json({ error: 'unsupported language' })
}
res.cookie('language', language, { ...preferenceCookie, maxAge: oneYear })
res.status(204).end()
})
app.delete('/preferences', (req, res) => {
res.clearCookie('language', preferenceCookie)
res.status(204).end()
})
app.listen(3000, (error) => {
if (error) throw error
console.log('Listening on http://localhost:3000')
})
cookie-parser
cookie-parser (1.4.7) parses the Cookie header and fills two properties: req.cookies with the plain cookies and
req.signedCookies with the cookies whose signature verified. Without it both are undefined in Express 4 and 5.
import cookieParser from 'cookie-parser'
app.use(cookieParser(process.env.COOKIE_SECRET))
The signature is cookieParser([secret, options]):
-
secretis a string or an array of strings. Without it signed cookies are not processed. -
optionsis passed to thecookiepackage’sparsefunction; the one option that matters isdecode, a function that decodes the value (defaultdecodeURIComponent).
Behaviour worth knowing:
-
A value starting with
j:is parsed as JSON; if parsing fails the raw string is kept. This is why a user-controlled cookie must never be passed to code that assumes a string. -
Only the first occurrence of a name is kept when the header repeats it.
-
The package also exposes the helpers
cookieParser.JSONCookie,cookieParser.JSONCookies,cookieParser.signedCookieandcookieParser.signedCookies, used when cookies are read outside of a request (for example when authenticating a WebSocket upgrade). -
express-sessionandcookie-sessionread the header on their own, socookie-parseris not required for sessions. Using both is only needed when other code readsreq.cookies.
Signed cookies
A signed cookie carries an HMAC of its value. res.cookie('theme', 'dark', { signed: true }) stores s:dark.<signature>,
and cookie-parser verifies the signature when it parses the request. A valid cookie appears in req.signedCookies.theme
with the prefix and signature stripped; a cookie with a bad signature appears as false, and a missing one as
undefined.
Two properties of signing are frequently misunderstood:
-
A signature gives integrity, not confidentiality. The value is readable by anyone who holds the cookie, so secrets, passwords and personal data do not belong in it, even signed.
-
A signature gives no freshness. A copy of a valid signed cookie stays valid until the secret changes, so a stolen cookie can be replayed and a "logged out" cookie cannot be revoked server-side.
Rotating the secret
Pass an array of secrets to rotate without logging everybody out: the first element signs new cookies, and every
element is tried when verifying. To rotate, add the new secret at the front, keep the old one for as long as the longest
cookie lifetime, then drop it. The same rule applies to the secret option of express-session and to the keys option
of cookie-session.
// secrets.js: one comma-separated variable, newest first.
// SESSION_SECRETS=<new-random-value>,<previous-random-value>
export const secrets = (process.env.SESSION_SECRETS ?? '')
.split(',')
.map((secret) => secret.trim())
.filter(Boolean)
if (secrets.length === 0) {
throw new Error('SESSION_SECRETS must contain at least one secret')
}
Generate every secret with a cryptographically secure source, for example
node -e "console.log(require('node:crypto').randomBytes(32).toString('base64url'))", and store it in the platform’s
secret manager. Secrets committed to the repository, as in several books' sample projects, are public the moment the
repository is.
A signed cookie for a non-authoritative value
The example remembers which shelf the reader last opened. A tampered cookie is detected and ignored, and the route reads
req.signedCookies, never req.cookies.
import express from 'express'
import cookieParser from 'cookie-parser'
import { secrets } from './secrets.js'
const app = express()
app.use(cookieParser(secrets))
app.get('/shelves/:id', (req, res) => {
res.cookie('lastShelf', req.params.id, {
signed: true,
httpOnly: true,
sameSite: 'lax',
maxAge: 7 * 24 * 60 * 60 * 1000,
})
res.json({ shelf: req.params.id })
})
app.get('/continue', (req, res) => {
const { lastShelf } = req.signedCookies
if (!lastShelf) {
// undefined: never set. false: present but the signature failed.
return res.redirect(303, '/shelves')
}
res.redirect(303, `/shelves/${encodeURIComponent(lastShelf)}`)
})
Sessions: two models
A session lets the server recognise the same browser across requests. There are two architectures, and Express’s official middleware implements one each:
| Aspect | cookie-session (client-side) |
express-session (server-side) |
|---|---|---|
Where the data lives |
In the cookie itself (JSON, base64-encoded, signed). |
In a store on the server. The cookie holds only a signed session id. |
Dependencies |
None: no store to operate. |
A store. The default |
Size |
About 4 KB, the browser’s limit for one cookie. |
Limited only by the store. |
Confidentiality |
The client can decode and read the data; it is signed, not encrypted. |
The client sees only an opaque id. |
Revocation |
Not possible: a copy of the cookie stays valid until it expires or the keys rotate. |
Delete the record in the store to log a user out everywhere. |
Scaling |
Stateless; any instance can serve any request. |
Needs a shared store (Redis, MongoDB, SQL) so every instance sees the same sessions. |
Typical use |
Flash messages, small preferences, prototypes. |
Authenticated users, shopping carts, anything with a security consequence. |
Stateless JSON Web Tokens are a third model, compared in Authentication & Authorization and in Node.js: Authentication & Sessions.
cookie-session
cookie-session (2.1.1) keeps the session object in the cookie. It adds req.session, a plain object, and signs the
cookie with a second cookie called <name>.sig.
| Option | Default | Meaning |
|---|---|---|
|
|
Cookie name. |
|
none |
Array of secrets (or a |
|
none |
A single secret used when |
|
|
The cookie attributes, set in the options object itself (not in a nested |
|
|
Whether the cookie is signed. Turning it off removes tamper protection. |
|
|
Overwrite a previous cookie of the same name instead of appending a second header. |
Three behaviours are specific to the package:
-
req.session = nulldestroys the session, andreq.session.isNew,req.session.isChangedandreq.session.isPopulateddescribe its state. Assigning anything other than an object ornullthrows. -
The cookie is written only when the session was modified; there is no
resaveconcept. -
With
secure: truethe cookie is refused on a connection that Express believes is plain HTTP. Behind a TLS-terminating proxy the application must settrust proxy(seetrust proxyandsecurecookies), otherwise the package throws "Cannot send secure cookie over unencrypted connection".
The size limit and the lack of revocation are the reasons not to use it for authentication unless the application accepts
that a stolen cookie works until it expires. Keep the data minimal (a user id, not a user), keep maxAge short, and
rotate the keys periodically.
A visit counter with cookie-session
This follows the shape of the official cookie-sessions example: a counter that survives across requests with no server
state.
import express from 'express'
import cookieSession from 'cookie-session'
import { secrets } from './secrets.js'
const app = express()
app.set('trust proxy', 1) // so `secure` works behind one TLS-terminating proxy
app.use(
cookieSession({
name: '__Host-visits',
keys: secrets, // first signs, all verify
maxAge: 24 * 60 * 60 * 1000,
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
}),
)
app.get('/', (req, res) => {
req.session.views = (req.session.views ?? 0) + 1
res.json({ views: req.session.views })
})
app.post('/reset', (req, res) => {
req.session = null // destroys the cookie
res.status(204).end()
})
The __Host- name only works once secure is on, so in a local HTTP run switch to a plain name such as visits through
an environment variable.
express-session
express-session (1.19.0) is the server-side session middleware. It creates a session id, stores it in a signed cookie,
and keeps the data in a store. The middleware page on the Express site is
/en/resources/middleware/session/. It adds req.session,
req.sessionID and req.sessionStore.
Options
| Option | Default | Meaning and recommendation |
|---|---|---|
|
required |
String or array (rotation: first signs, all verify). Use |
|
|
Cookie name. The default reveals the framework, so set your own, and use |
|
none (deprecation warning) |
Forces the session to be saved back to the store even when it was not modified. Set it to |
|
none (deprecation warning) |
Saves a new but unmodified session. Set it to |
|
|
Re-sends the cookie on every response so the expiry slides with activity. Together with a short |
|
|
The cookie attributes: |
|
|
The session store; see Session stores. |
|
|
Function that returns a new id. The default is cryptographically random; replace it only to add a prefix, never with anything predictable. |
|
|
Trust |
|
|
What happens when |
Since express-session 1.5.0 it no longer reads req.cookies, so cookie-parser is unnecessary (and sharing a
different secret between the two only causes confusion). The middleware must be registered before every route and
middleware that uses req.session; one of the errata in the older Node.js books is a session-dependent middleware placed
above session().
The session object
-
req.session.regenerate(callback)generates a new id and an empty session, then runs the callback withreq.sessionpointing at it. It is the cure for session fixation (see Session fixation andreq.session.regenerate()). -
req.session.destroy(callback)removes the session from the store and unsetsreq.session. -
req.session.reload(callback)re-reads the session from the store, discarding unsaved changes. -
req.session.save(callback)writes the session to the store now. The middleware saves automatically at the end of the response, but a redirect can reach the browser before that write completes, so callsaveexplicitly beforeres.redirect()after login. -
req.session.touch()resets the idle timer without changing data. Called automatically withrollingor whenresaveisfalse. -
req.session.cookieis the cookie object for this session, withoriginalMaxAge,expiresand the per-session override of any attribute (for example a longermaxAgewhen the user ticks "remember me"). -
req.sessionIDis the id. It is a credential: never log it and never put it in a URL.
Session stores
A store implements get(sid, cb), set(sid, session, cb) and destroy(sid, cb), and optionally touch, all, length
and clear. Everything the application keeps in req.session is serialised (usually as JSON), so a Date returns as a
string and a Map as an empty object.
MemoryStore: development only
The default store keeps sessions in the process’s memory. The official documentation warns that it is not designed for a production environment: it leaks memory under load, loses every session on restart, and does not work with more than one process or instance because each has its own copy. The warning printed at startup is accurate, and choosing a real store is not optional once the application leaves a laptop.
connect-redis
connect-redis (10.0.0) keeps each session as a Redis key with a TTL. Version 10 requires Node.js 22 or later and the
redis client (node-redis) at version 5 or later, plus express-session. It exports a named RedisStore class; the
connect-redis(session) factory of old tutorials is not used. Options: client, prefix (default
sess:; give each application its own prefix when they share an instance), ttl (seconds, a number or a function of the
session, used when the cookie has no expires; default one day), disableTouch, disableTTL, serializer and
scanCount.
import express from 'express'
import session from 'express-session'
import { RedisStore } from 'connect-redis'
import { createClient } from 'redis'
const redisClient = createClient({ url: process.env.REDIS_URL })
redisClient.on('error', (error) => console.error('Redis error', error))
await redisClient.connect()
const app = express()
app.set('trust proxy', 1)
app.use(
session({
store: new RedisStore({ client: redisClient, prefix: 'bookshelf:sess:' }),
name: '__Host-sid',
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
rolling: true,
cookie: {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 30 * 60 * 1000, // 30 minutes of inactivity
},
}),
)
// Close the client during graceful shutdown.
process.on('SIGTERM', async () => {
await redisClient.quit()
})
Source: tj/connect-redis
connect-mongo
connect-mongo (6.0.0, Node.js 20.8 or later, mongodb driver 5 or later as a peer) stores sessions in a MongoDB
collection. The factory of the old books, require('connect-mongo')(session), no longer exists: create the store with
MongoStore.create(options). One connection option is required, by priority mongoUrl, clientPromise or client;
reusing the application’s existing MongoClient (or Mongoose connection’s client) avoids a second pool.
| Option | Default | Meaning |
|---|---|---|
|
|
Collection that holds the sessions. |
|
from the URL |
Database, when the connection string has none. |
|
|
Seconds of life, used when the cookie has no |
|
|
|
|
|
Seconds between "touch" writes for an unmodified session; with |
|
|
Serialise with JSON. Keep it unless the session contains types that MongoDB stores natively. |
|
none |
Encrypts the session payload at rest (for example |
|
|
Adds |
import express from 'express'
import session from 'express-session'
import MongoStore from 'connect-mongo'
const app = express()
app.set('trust proxy', 1)
app.use(
session({
store: MongoStore.create({
mongoUrl: process.env.MONGODB_URI,
collectionName: 'sessions',
ttl: 14 * 24 * 60 * 60, // seconds
touchAfter: 24 * 60 * 60, // seconds: lazy touch for unmodified sessions
}),
name: '__Host-sid',
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 14 * 24 * 60 * 60 * 1000 },
}),
)
Source: jdesboeufs/connect-mongo
Other stores and choosing one
The Express site lists compatible stores (SQL databases through Sequelize or Knex based stores, Memcached, DynamoDB,
Firestore, file stores and more) next to the express-session page. Choose by what the application already runs:
-
Redis is the default choice when sessions are a high-write, short-lived workload and the deployment already has it.
-
MongoDB fits when the application already depends on it and a second datastore is unwanted (see MongoDB & Mongoose).
-
A SQL store fits when sessions must survive a cache flush or be queried together with user data.
-
Whatever the store, set a TTL, give it a unique prefix or collection, make it highly available if logout-on-failure is unacceptable, and decide what the application does when the store is down: depending on the store and the failure, requests can continue with
req.sessionundefined, so authenticated routes must fail closed.
A secure session configuration
The checklist below pulls the options above together. Every line fixes a weakness found in common older tutorials.
| Setting | Recommended | Why |
|---|---|---|
|
Long random value from |
A committed or guessable secret lets an attacker forge cookies. |
|
Custom, |
Hides the framework and blocks overwriting from a sibling subdomain. |
|
|
Avoids lost updates and needless writes. |
|
|
No store entry or cookie until there is something worth keeping. |
|
|
Injected scripts cannot read the id. |
|
|
The id never travels over HTTP. |
|
|
Basic protection against cross-site request forgery; pair with a token library (see Security Best Practices). |
|
Short idle timeout, absolute limit checked in code |
Limits the window for a stolen id. |
|
Redis, MongoDB or SQL store |
|
Login |
|
Prevents session fixation. |
Logout |
|
Removes the server record and the browser’s copy. |
Session fixation and req.session.regenerate()
In a session-fixation attack the attacker obtains a valid anonymous session id (by visiting the site), plants it in the
victim’s browser (through a subdomain cookie, an injected script on a sibling site, or a URL on very old setups), waits
for the victim to log in, and then uses the id, which is now an authenticated session. The defence is simple: issue a
new session id whenever the privilege level changes. In Express that is req.session.regenerate() at login (and at
privilege elevation such as completing two-factor authentication).
regenerate replaces the session object, so anything needed afterwards (a cart, a "return to" URL) must be read before
the call and copied across. Passport 0.6 and later regenerate the session in req.login() themselves, a change that broke
tutorials which stored data in the pre-login session.
The sequence below shows the flow implemented in the example that follows.
Session login with regeneration
The example is a complete, runnable server using the in-memory store only so that it works without infrastructure;
replace the store as shown in the previous sections for production. Password verification uses the node:crypto module’s
scrypt against a hash computed at startup from an environment variable, which stands in for a user table.
// app.js: node --watch --env-file=.env app.js
// .env (placeholders, never commit real values):
// SESSION_SECRET=<random-value>
// DEMO_PASSWORD=<password-for-the-demo-user>
import express from 'express'
import session from 'express-session'
import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto'
import { promisify } from 'node:util'
const scryptAsync = promisify(scrypt)
async function hash(password, salt = randomBytes(16)) {
const key = await scryptAsync(password, salt, 64)
return { salt, key }
}
// A stand-in for a users table.
const demo = await hash(process.env.DEMO_PASSWORD)
const users = new Map([['ada', { id: 42, name: 'ada', ...demo }]])
async function authenticate(name, password) {
const user = users.get(name)
// Hash even for unknown users so the timing does not reveal which names exist.
const candidate = await hash(String(password), user?.salt ?? randomBytes(16))
if (!user || !timingSafeEqual(candidate.key, user.key)) return null
return user
}
const app = express()
app.set('trust proxy', 1)
app.use(express.json())
const production = process.env.NODE_ENV === 'production'
const cookieName = production ? '__Host-sid' : 'sid'
const cookieOptions = { httpOnly: true, secure: production, sameSite: 'lax', path: '/' }
app.use(
session({
name: cookieName,
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: { ...cookieOptions, maxAge: 30 * 60 * 1000 },
}),
)
function requireLogin(req, res, next) {
if (!req.session.userId) return res.status(401).json({ error: 'authentication required' })
next()
}
app.post('/login', async (req, res, next) => {
const user = await authenticate(req.body?.username, req.body?.password)
if (!user) return res.status(401).json({ error: 'invalid credentials' })
// New id for the new privilege level; the old session is deleted.
req.session.regenerate((error) => {
if (error) return next(error)
req.session.userId = user.id
req.session.loggedInAt = Date.now()
// Save before responding so the next request already finds the record.
req.session.save((saveError) => {
if (saveError) return next(saveError)
res.json({ id: user.id, name: user.name })
})
})
})
app.get('/account', requireLogin, (req, res) => {
res.json({ userId: req.session.userId, loggedInAt: req.session.loggedInAt })
})
app.post('/logout', (req, res, next) => {
req.session.destroy((error) => {
if (error) return next(error)
res.clearCookie(cookieName, cookieOptions)
res.status(204).end()
})
})
app.listen(3000, (error) => {
if (error) throw error
console.log('Listening on http://localhost:3000')
})
The async login handler relies on Express 5 forwarding a rejected promise to the error handlers; in Express 4 wrap it
or add a catch that calls next. The /logout route is a POST because a GET logout can be triggered by an image
tag on another site. Its clearCookie call repeats the name and the attributes configured for the session; the shared cookieOptions
constant guarantees they match.
Logging out
Logging out is two actions with different owners: the server must forget the session, and the browser must drop the cookie.
-
req.session.destroy(callback)deletes the record in the store. This is the step that actually ends the session; with a copied cookie it is the only thing that stops an attacker. -
res.clearCookie(name, options)removes the cookie from the browser, with the samepathanddomainused when it was set.
For cookie-session, req.session = null is the equivalent of both, and cannot invalidate a copy of the cookie. To log
a user out of every device, store sessions by user (for example a sess:user:42: key prefix in Redis, or a userId field
in the MongoDB document) and delete them all, which only server-side sessions allow.
trust proxy and secure cookies
Production applications almost always sit behind a TLS-terminating proxy: Nginx, a cloud load balancer, a platform router or a Kubernetes ingress. The proxy speaks HTTPS to the browser and plain HTTP to Express, so Express sees an insecure connection. Consequences:
-
req.secureisfalseandreq.protocolishttp. -
express-sessionwithcookie.secure: truedoes not send the cookie (the response has noSet-Cookie), so every request is a new anonymous session and login appears to do nothing. This is the most common production failure and the reason older tutorials said to setsecure: false.cookie-sessionthrows instead. -
req.ipis the proxy’s address, which breaks rate limiting and logs.
The fix is the application setting trust proxy, so Express reads X-Forwarded-Proto and X-Forwarded-For from the proxy:
// One proxy (a load balancer or ingress) in front of Express.
app.set('trust proxy', 1)
Source: Express: Express behind proxies
The value must describe the real network. It accepts true (trust everything, only safe when the app is reachable
exclusively through the proxy), a number of hops, an array or comma-separated list of IPs and subnets (with the
shorthands loopback, linklocal and uniquelocal), or a function. Trusting too much lets a client send its own
X-Forwarded-For or X-Forwarded-Proto and spoof the address that rate limiters see or make the app believe the request
was secure. The complete treatment, the headers each hop adds and the 2026 proxy-addr fix are in
Behind Proxies.
Verify the setting from the outside: after login, curl -i against the HTTPS URL must show a Set-Cookie header with
Secure; HttpOnly; SameSite=Lax. The proxy must also forward X-Forwarded-Proto; Nginx needs
proxy_set_header X-Forwarded-Proto $scheme;.
What changed recently
| Change | Effect |
|---|---|
Express 5: |
Reusing one options object for set and clear is safe; Express 4 code that stripped |
Express 5: |
Nothing moved into the core; the middleware keeps its own release cycle (1.4.7). |
|
Omitting them prints a deprecation warning; set both explicitly. |
|
Current release; accepts the |
|
The old factory |
|
|
Passport 0.6 and later |
|
|
Combine |
Where the books are outdated
The books consulted for this section teach cookie and session handling as it was in Express 4.x, and several of their defaults are now known to be unsafe.
| Older practice | Current practice |
|---|---|
|
Both |
|
|
The default |
A real store ( |
|
|
A session secret written in the source or a committed |
Secret from |
No |
|
Middleware that reads |
Register the session middleware first; the stack is ordered. |
Logging in by setting |
|
Logout with a |
|
|
Not required since |
|
|
Common mistakes
| Symptom | Cause | Fix |
|---|---|---|
Login works locally, never in production. |
|
|
The user is logged out at random in production. |
|
Use a shared store; do not rely on sticky sessions. |
|
|
Register |
|
|
Pass a secret or array to |
The cookie is not deleted on logout. |
|
Share one options constant. |
A redirect after login lands on a logged-out page. |
The redirect reached the browser before the store write finished. |
Call |
The session cookie is huge and requests fail. |
Too much data in |
Store only an id; keep data in the database. |
After |
Data from the old session was not copied to the new one. |
Read it before calling |
Embedded widget gets no cookie. |
|
Use |
References
-
Express API: Response (
res.cookie,res.clearCookie) -
Express API: Request (
req.cookies,req.signedCookies,req.secure) -
Publisher pages of the consulted books are listed in the section bibliography.
-
Related pages: Authentication & Authorization, Security Best Practices, Behind Proxies, CORS, Response, Request