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.

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=value pairs come back, so the server cannot tell whether a cookie was httpOnly, secure or 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(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

domain

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.

encode

encodeURIComponent

Synchronous function used to encode the value. Use String to leave the value as it is when it is already safe.

expires

none (session cookie)

A Date after which the cookie is deleted by the browser. Without expires or maxAge the cookie lives until the browser session ends, which for modern browsers with session restore can be much longer than the name suggests.

httpOnly

false

Hides the cookie from document.cookie, so an injected script cannot read it. Set it for every cookie that is not meant for client-side code. Note that Express’s own default is false, while express-session defaults it to true.

maxAge

none

Lifetime in milliseconds, converted to Max-Age (seconds) and to Expires. Mixing it up with seconds is the classic bug: maxAge: 3600 is 3.6 seconds.

path

'/'

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.

partitioned

false

Adds the Partitioned attribute (CHIPS, cookies having independent partitioned state). The cookie is stored per top-level site, which keeps embedded third-party widgets working after third-party cookies are blocked. It requires secure.

priority

none

'low', 'medium' or 'high'. A hint to the browser about which cookies to evict first when it runs out of space.

secure

false

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.

signed

false

Signs the value with the secret given to cookieParser(secret) and stores it as s:<value>.<signature>. Requires cookie-parser with a secret, otherwise Express throws.

sameSite

none

true (same as 'strict'), false (no attribute), 'lax', 'strict' or 'none'. See the next section.

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 the Origin header, blocks most cross-site request forgery. Chromium-based browsers treat a cookie without the attribute as Lax, 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 also secure. A cross-origin front end also needs the CORS credentials configuration described in CORS.

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() ignores maxAge and expires: it always sends an epoch Expires. In Express 4 a maxAge passed to clearCookie overrode the expiry and the cookie was not deleted, which tutorials worked around by omitting it. Passing the same options object that was used for res.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).

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 (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]):

  • secret is a string or an array of strings. Without it signed cookies are not processed.

  • options is passed to the cookie package’s parse function; the one option that matters is decode, a function that decodes the value (default decodeURIComponent).

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.signedCookie and cookieParser.signedCookies, used when cookies are read outside of a request (for example when authenticating a WebSocket upgrade).

  • express-session and cookie-session read the header on their own, so cookie-parser is not required for sessions. Using both is only needed when other code reads req.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.

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 MemoryStore is for development only.

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 (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

name

'session'

Cookie name.

keys

none

Array of secrets (or a Keygrip instance). keys[0] signs, all keys verify, which is how rotation works.

secret

none

A single secret used when keys is not set. One of the two is required.

maxAge, expires, path, domain, sameSite, secure, httpOnly

httpOnly: true, path: '/'

The cookie attributes, set in the options object itself (not in a nested cookie object).

signed

true

Whether the cookie is signed. Turning it off removes tamper protection.

overwrite

true

Overwrite a previous cookie of the same name instead of appending a second header.

Three behaviours are specific to the package:

  • req.session = null destroys the session, and req.session.isNew, req.session.isChanged and req.session.isPopulated describe its state. Assigning anything other than an object or null throws.

  • The cookie is written only when the session was modified; there is no resave concept.

  • With secure: true the cookie is refused on a connection that Express believes is plain HTTP. Behind a TLS-terminating proxy the application must set trust proxy (see trust proxy and secure cookies), 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.

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

secret

required

String or array (rotation: first signs, all verify). Use process.env.SESSION_SECRET or a list loaded from a secret manager.

name

'connect.sid'

Cookie name. The default reveals the framework, so set your own, and use __Host-sid in production.

resave

none (deprecation warning)

Forces the session to be saved back to the store even when it was not modified. Set it to false. It is only needed for a store that does not implement touch and expires idle sessions, and with true two parallel requests can overwrite each other’s changes.

saveUninitialized

none (deprecation warning)

Saves a new but unmodified session. Set it to false: it avoids filling the store with an empty session for every anonymous visitor (crawlers, health checks) and complies with consent laws that forbid setting cookies before consent.

rolling

false

Re-sends the cookie on every response so the expiry slides with activity. Together with a short cookie.maxAge it gives an idle timeout. For an absolute timeout store the creation time in the session and check it.

cookie

{ path: '/', httpOnly: true, secure: false, maxAge: null }

The cookie attributes: domain, expires, httpOnly, maxAge, partitioned, path, priority, sameSite, secure. maxAge: null makes a browser-session cookie. secure can also be 'auto', which follows the connection.

store

MemoryStore

The session store; see Session stores.

genid

uid-safe (24 bytes)

Function that returns a new id. The default is cryptographically random; replace it only to add a prefix, never with anything predictable.

proxy

undefined

Trust X-Forwarded-Proto for secure cookies. When undefined the middleware uses Express’s trust proxy setting, which is the setting to use. true and false override it for this middleware only.

unset

'keep'

What happens when req.session is deleted (delete req.session or req.session = null): 'keep' leaves the stored copy, 'destroy' removes it.

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 with req.session pointing at it. It is the cure for session fixation (see Session fixation and req.session.regenerate()).

  • req.session.destroy(callback) removes the session from the store and unsets req.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 call save explicitly before res.redirect() after login.

  • req.session.touch() resets the idle timer without changing data. Called automatically with rolling or when resave is false.

  • req.session.cookie is the cookie object for this session, with originalMaxAge, expires and the per-session override of any attribute (for example a longer maxAge when the user ticks "remember me").

  • req.sessionID is 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()
})

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

collectionName

'sessions'

Collection that holds the sessions.

dbName

from the URL

Database, when the connection string has none.

ttl

1209600 (14 days)

Seconds of life, used when the cookie has no expires.

autoRemove

'native'

'native' creates a MongoDB TTL index, 'interval' deletes expired sessions from the application every autoRemoveInterval minutes (for engines without TTL indexes), 'disabled' leaves cleanup to you.

touchAfter

0

Seconds between "touch" writes for an unmodified session; with resave: false a value such as 24 * 3600 cuts write traffic to one per day per session.

stringify

true

Serialise with JSON. Keep it unless the session contains types that MongoDB stores natively.

cryptoAdapter

none

Encrypts the session payload at rest (for example createWebCryptoAdapter({ secret }), AES-GCM). The older crypto option still works.

timestamps

false

Adds createdAt and updatedAt fields to each session document.

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 },
  }),
)

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.session undefined, 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

secret

Long random value from process.env, array for rotation

A committed or guessable secret lets an attacker forge cookies.

name

Custom, __Host-sid in production

Hides the framework and blocks overwriting from a sibling subdomain.

resave

false

Avoids lost updates and needless writes.

saveUninitialized

false

No store entry or cookie until there is something worth keeping.

cookie.httpOnly

true

Injected scripts cannot read the id.

cookie.secure

true in production (and trust proxy configured)

The id never travels over HTTP.

cookie.sameSite

'lax' (or 'strict')

Basic protection against cross-site request forgery; pair with a token library (see Security Best Practices).

cookie.maxAge and rolling

Short idle timeout, absolute limit checked in code

Limits the window for a stolen id.

store

Redis, MongoDB or SQL store

MemoryStore leaks and is per process.

Login

req.session.regenerate()

Prevents session fixation.

Logout

req.session.destroy() and res.clearCookie()

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.

sequenceDiagram autonumber participant B as Browser participant E as Express participant S as Session store participant U as User lookup B->>E: GET /login E->>S: create anonymous session abc (only if modified) E-->>B: Set-Cookie sid=abc B->>E: POST /login with credentials, Cookie sid=abc E->>U: verify username and password U-->>E: user id 42 E->>E: req.session.regenerate() E->>S: delete abc, create session xyz E->>E: req.session.userId = 42 E->>S: req.session.save() E-->>B: 303 redirect, Set-Cookie sid=xyz B->>E: GET /account, Cookie sid=xyz E->>S: load xyz S-->>E: userId 42 E-->>B: 200 account page B->>E: POST /logout, Cookie sid=xyz E->>S: destroy xyz E-->>B: 204, clear cookie sid

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.

  1. 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.

  2. res.clearCookie(name, options) removes the cookie from the browser, with the same path and domain used 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.secure is false and req.protocol is http.

  • express-session with cookie.secure: true does not send the cookie (the response has no Set-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 set secure: false. cookie-session throws instead.

  • req.ip is 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)

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: res.clearCookie() ignores maxAge and expires

Reusing one options object for set and clear is safe; Express 4 code that stripped maxAge can stay as it is.

Express 5: req.cookies still needs cookie-parser

Nothing moved into the core; the middleware keeps its own release cycle (1.4.7).

express-session: resave and saveUninitialized have no default

Omitting them prints a deprecation warning; set both explicitly.

express-session 1.19

Current release; accepts the partitioned and priority cookie attributes in cookie.

connect-redis (current 10.0.0)

The old factory require('connect-redis')(session) is replaced by the named RedisStore class; version 10 needs the redis client 5 or later and Node.js 22 or later.

connect-mongo (current 6.0.0)

MongoStore.create() replaced the factory; cryptoAdapter (Web Crypto AES-GCM) is the preferred encryption hook.

Passport 0.6 and later

req.logout() takes a callback, and req.login() regenerates the session.

csurf deprecated (2025-05-16)

Combine SameSite cookies with a maintained token library; see Security Best Practices.

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

express-session with resave: true and saveUninitialized: true

Both false; they are required options with a deprecation warning when omitted.

httpOnly: false, no secure and no sameSite

httpOnly: true, secure: true in production, sameSite: 'lax', and a custom cookie name.

The default MemoryStore in the sample projects

A real store (connect-redis, connect-mongo, a SQL store). MemoryStore is for development only.

require('connect-mongo')(session) and new MongoStore({ mongooseConnection })

MongoStore.create({ mongoUrl }) or { client }.

A session secret written in the source or a committed .env file

Secret from process.env.SESSION_SECRET (or a secret manager) with array rotation; the committed ones must be treated as leaked.

No trust proxy when deploying to Heroku or similar platforms

app.set('trust proxy', 1) (or the right hop count), otherwise secure cookies are never set.

Middleware that reads req.session registered above session()

Register the session middleware first; the stack is ordered.

Logging in by setting req.session.user on the existing session

req.session.regenerate() at login to prevent fixation; Passport 0.6 does it for you.

Logout with a GET route and no cookie clearing

POST logout that destroys the session and clears the cookie.

cookie-parser required for express-session

Not required since express-session 1.5.0.

res.cookie(name, value, { maxAge: 3600 }) meaning an hour

maxAge is in milliseconds; 3600 is 3.6 seconds.

Common mistakes

Symptom Cause Fix

Login works locally, never in production.

secure: true behind a TLS-terminating proxy without trust proxy.

app.set('trust proxy', 1) and make the proxy forward X-Forwarded-Proto.

The user is logged out at random in production.

MemoryStore with several instances or restarts; or a load balancer without a shared store.

Use a shared store; do not rely on sticky sessions.

req.cookies is undefined.

cookie-parser is missing or registered after the route.

Register cookieParser() before the routes.

res.cookie(..., { signed: true }) throws.

cookieParser() was created without a secret.

Pass a secret or array to cookieParser(secret).

The cookie is not deleted on logout.

clearCookie options differ from the ones used to set it (path, domain).

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 req.session.save() before res.redirect().

The session cookie is huge and requests fail.

Too much data in cookie-session, or a large object in req.session.

Store only an id; keep data in the database.

After regenerate the cart is empty.

Data from the old session was not copied to the new one.

Read it before calling regenerate.

Embedded widget gets no cookie.

SameSite=Lax or missing Secure for 'none'.

Use sameSite: 'none', secure: true, and partitioned: true where supported.