In the sign-in handler, call req.session.regenerate(), then store the user and save, so no session ID from before sign-in survives. Name the cookie __Host-sid with secure, httpOnly, sameSite: 'lax' and path '/', use rolling: true with a 30-minute maxAge, trust your TLS proxy, and end sessions 8 hours after sign-in.
- In the sign-in route, after checking the password, call req.session.regenerate(), then set req.session.user and the sign-in time and call req.session.save() before redirecting, as express-session's own login example does. Read anything you still need, such as a return URL, before regenerating: the new session starts empty.
- Regenerate again whenever privileges change (switching accounts, stepping up to an admin role), and on sign-out call req.session.destroy() or regenerate() so the old ID stops working on the server.
- Name the cookie with the __Host- prefix and set cookie: { secure: true, httpOnly: true, sameSite: 'lax', path: '/' } with no domain. Browsers reject a __Host- cookie that is not Secure, has a Domain, or has another Path.
- Behind a TLS-terminating proxy, set app.set('trust proxy', ...) to that proxy's addresses. With secure: true and no trusted proxy, express-session sees plain HTTP and sends no cookie at all.
- Set cookie.maxAge to the idle limit (30 minutes here) and rolling: true, so every response renews the cookie and the stored session; in production use a store that expires sessions, not MemoryStore.
- Add an absolute limit: store the sign-in time in the session and regenerate it when it is older than the limit (8 hours here), because a rolling session never ends while it is used.
- Add regression tests that replay the pre-login cookie after sign-in, read the Set-Cookie attributes, and move a mocked clock (node:test mock.timers can mock Date, which express-session and MemoryStore read) past both limits.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
export function signIn(req, res, next, username) {
// Mark the visitor's session as signed in. Its ID stays the same.
req.session.user = username;
res.redirect(303, req.session.returnTo ?? '/account');
}With IMPL=vulnerable, 7 of 11 tests fail (7 exploit tests).
The fix: a new session ID at sign-in
export function signIn(req, res, next, username) {
const returnTo = req.session.returnTo ?? '/account';
// regenerate() deletes the visitor's session from the store and starts an empty one with a
// new ID, so an ID that was planted or seen before sign-in is worthless after it.
req.session.regenerate((err) => {
if (err) return next(err);
req.session.user = username;
req.session.signedInAt = Date.now();
// Save before redirecting, so the next request finds the new session.
req.session.save((err) => (err ? next(err) : res.redirect(303, returnTo)));
});
}With IMPL=fixed, 11 of 11 tests pass.
The fix: cookie attributes and timeouts
const IDLE_TIMEOUT_MS = 30 * 60 * 1000;
const ABSOLUTE_TIMEOUT_MS = 8 * 60 * 60 * 1000;
export function sessionMiddleware({ secret }) {
return [
session({
name: '__Host-sid', // browsers accept a __Host- cookie only with Secure, Path=/ and no Domain
secret,
resave: false,
saveUninitialized: false,
rolling: true, // every response renews the expiry, so maxAge works as an idle timeout
cookie: { secure: true, httpOnly: true, sameSite: 'lax', path: '/', maxAge: IDLE_TIMEOUT_MS },
}),
// express-session has no absolute timeout, so end the session 8 hours after sign-in.
function absoluteTimeout(req, res, next) {
const { signedInAt } = req.session;
if (signedInAt === undefined || Date.now() - signedInAt < ABSOLUTE_TIMEOUT_MS) return next();
req.session.regenerate(next); // deletes the stored session; the request goes on signed out
},
];
}With IMPL=fixed, 11 of 11 tests pass.
A fix that looks safe but is not
export function signIn(req, res, next, username) {
const returnTo = req.session.returnTo ?? '/account';
// The cookie is locked down and signed with our secret, so keep the session and mark it
// signed in. Its ID stays the same.
delete req.session.returnTo;
req.session.user = username;
req.session.signedInAt = Date.now();
req.session.save((err) => (err ? next(err) : res.redirect(303, returnTo)));
}With IMPL=wrong, 2 of 11 tests fail (2 exploit tests).
The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
describe(`exploit (IMPL=${implName})`, () => {
test('a session ID issued before sign-in does not work after the victim signs in', async () => {
// The attacker opens the site and is given a session ID without signing in...
const attacker = new Browser();
await attacker.send('GET', '/account', { headers: TYPED_URL });
await attacker.send('GET', '/login', { headers: SAME_ORIGIN_PAGE });
// ...and the victim signs in on a browser that already holds it: a shared computer the
// attacker used first, or a cookie written from a sibling subdomain (a __Host- cookie can't be).
const victim = attacker.clone();
await signIn('alice', victim);
assert.equal(await signedInAs(victim), 'alice');
assert.notEqual(await signedInAs(attacker), 'alice', "the pre-login session ID opens Alice's account");
});
test("an attacker's own session planted in the victim's browser does not become the victim's", async () => {
const bob = await signIn('bob');
const victim = bob.clone(); // Bob plants his signed-in session cookie in Alice's browser
await signIn('alice', victim);
assert.equal(await signedInAs(victim), 'alice');
assert.notEqual(await signedInAs(bob), 'alice', "Bob's session now opens Alice's account");
});
test('the session cookie is HttpOnly, so page scripts cannot read it', async () => {
const { attributes, line } = await sessionCookie(await signIn('alice'));
assert.ok(attributes.has('httponly'), `no HttpOnly in Set-Cookie: ${line}`);
});
test('the session cookie is Secure, so the browser never sends it over plain HTTP', async () => {
const { attributes, line } = await sessionCookie(await signIn('alice'));
assert.ok(attributes.has('secure'), `no Secure in Set-Cookie: ${line}`);
});
test('the session cookie is SameSite=Lax or Strict', async () => {
const { attributes, line } = await sessionCookie(await signIn('alice'));
assert.match(attributes.get('samesite') ?? '', /^(lax|strict)$/i, `Set-Cookie: ${line}`);
});
test('the session cookie has the __Host- prefix, Path=/ and no Domain', async () => {
const { name, attributes, line } = await sessionCookie(await signIn('alice'));
assert.ok(name.startsWith('__Host-'), `the session cookie is called ${name}`);
assert.ok(attributes.has('secure') && attributes.get('path') === '/' && !attributes.has('domain'), `Set-Cookie: ${line}`);
});
test(`a session left idle for ${timeline.idleFor} minutes no longer works`, async () => {
const alice = await signIn('alice');
clock.advance(timeline.idleFor);
assert.equal(await signedInAs(alice), null, `the session still works after ${timeline.idleFor} idle minutes`);
});
test('a session kept busy stops working 8 hours after sign-in', async () => {
const alice = await signIn('alice');
for (let t = timeline.pollEvery; t <= timeline.busyUntil; t += timeline.pollEvery) {
clock.advance(timeline.pollEvery);
assert.equal(await signedInAs(alice), 'alice', `signed out after ${t} busy minutes`);
}
clock.advance(timeline.pastAbsoluteLimit - timeline.busyUntil);
assert.equal(await signedInAs(alice), null, `the session still works ${timeline.pastAbsoluteLimit} minutes after sign-in`);
});
});- saveUninitialized: false does not prevent pre-login sessions. The first write to req.session (a return URL, a flash message, a cart) creates one, and its ID survives sign-in unless you regenerate.
- Signing the cookie with the secret stops forged IDs, not fixation: the attacker uses a real ID the server issued to them.
- regenerate() deletes the old session from the store. Hand-rolled rotation that copies the data under a new ID and leaves the old entry in the store keeps the old ID working.
- express-session's defaults are httpOnly: true, secure: false, no sameSite and no maxAge; MemoryStore keeps a session until the process restarts and is not meant for production.
- The __Host- prefix stops a sibling subdomain from setting the session cookie, a common way to plant an ID, but it does nothing when the attacker used the same browser first, as on a shared computer. Regenerate anyway.
- cookie.sameSite: 'lax' still sends the cookie on top-level GET navigations from other sites; 'strict' does not, but then users who follow a link to the site arrive signed out.
- Semgrep's express-cookie-settings rules match only CommonJS require('express-session'), and on CommonJS copies of this card two of them flag the fixed code: no-domain asks for a Domain, which a __Host- cookie must not have, and no-expires asks for cookie.expires instead of maxAge.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs session-cookies/node-express does this for you).
- Against your own app, save the cookie a visitor gets before signing in (curl -c jar -b jar https://your.app/account), sign in with the same jar, and check that the sign-in response sets a new session cookie and that the old value no longer opens the account page; check Secure, HttpOnly, SameSite and the name in that Set-Cookie line.
- Scanners see little of this: the Semgrep rules need CommonJS and two of them object to the correct configuration, and CodeQL's js/session-fixation only recognises a login route handler that writes req.session itself, so the regression tests are the control.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | javascript.express.security.audit.express-cookie-settings.express-cookie-session-no-secure | No findings on this card: like every rule in this file, it only matches apps that load the module with require('express-session'), and this card uses ES module imports. On CommonJS copies it flagged impl/vulnerable.js only.Checked: Semgrep 1.180.0 (CE) |
| Semgrep | javascript.express.security.audit.express-cookie-settings.express-cookie-session-no-httponly | No findings on this card (ES modules). On CommonJS copies it flagged impl/vulnerable.js, whose cookie is in fact HttpOnly: express-session sets httpOnly: true by default, and the rule only accepts it written out.Checked: Semgrep 1.180.0 (CE) |
| Semgrep | javascript.express.security.audit.express-cookie-settings.express-cookie-session-default-name | No findings on this card (ES modules). On CommonJS copies it flagged impl/vulnerable.js only, which keeps the default name connect.sid.Checked: Semgrep 1.180.0 (CE) |
| Semgrep | javascript.express.security.audit.express-cookie-settings.express-cookie-session-no-path | No findings on this card (ES modules). On CommonJS copies it flagged impl/vulnerable.js only.Checked: Semgrep 1.180.0 (CE) |
| Semgrep | javascript.express.security.audit.express-cookie-settings.express-cookie-session-no-domain | No findings on this card (ES modules). On CommonJS copies it flagged all three implementations, the fixed one included: it asks for cookie.domain, which a __Host- cookie must not have. Don't follow it for session cookies.Checked: Semgrep 1.180.0 (CE) |
| Semgrep | javascript.express.security.audit.express-cookie-settings.express-cookie-session-no-expires | No findings on this card (ES modules). On CommonJS copies it flagged all three implementations, the fixed one included: only cookie.expires satisfies it, while express-session's README says not to set expires and to use maxAge, which the fixed code does.Checked: Semgrep 1.180.0 (CE) |
| CodeQL | js/session-fixation | From the query source: it reports an Express route whose path contains 'login' when the handler writes a property of req.session and never reads req.session.regenerate (precision medium). In this card the /login handler in app.js passes req to impl.signIn() from a dynamically imported module; whether the query follows that call was not tested. Any read of req.session.regenerate satisfies it, so it cannot tell a correct rotation from one that leaves the old session in the store.Checked: github/codeql main at b5b8165; not executed |
| CodeQL | js/clear-text-cookie | Expected to flag the session() call in impl/vulnerable.js and not the fixed or wrong ones: javascript/ql/lib/semmle/javascript/frameworks/CookieLibraries.qll treats every express-session cookie as sensitive and secure only when cookie.secure is set to something other than false. The model finds express-session through moduleImport, so ES imports count.Checked: github/codeql main at b5b8165; not executed |
| CodeQL | js/client-exposed-cookie | No findings expected: the express-session model counts a cookie as HttpOnly unless cookie.httpOnly is explicitly false, which matches the library's default. No CodeQL query reports a missing SameSite attribute; js/samesite-none-cookie reports only SameSite=None.Checked: github/codeql main at b5b8165; not executed |
| Component | Version |
|---|---|
| Language | JavaScript (ES modules) |
| Runtime | Node.js 26.11.1 |
| Framework | Express 5.3.0 |
| express | 5.3.0 |
| express-session | 1.19.0 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the session-cookies/node-expressfolder.
npm ci# Expected: every test passes
IMPL=fixed npm test --silent
# Expected: an exploit test fails
IMPL=vulnerable npm test --silent
# Expected: a test fails
IMPL=wrong npm test --silentEach run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs session-cookies/node-express runs all three modes and checks the expected results.
Verified
- Machine
- macOS 27.0 (Darwin 27.0.0, arm64)
- Toolchain
- node 26.11.1
- npm 11.20.0
- express 5.3.0
- expressSession 1.19.0
- Matrix
- fixed: 11 of 11 tests pass; vulnerable: 7 of 11 tests fail (7 exploit tests); wrong: 2 of 11 tests fail (2 exploit tests)
- Test results
- fixed 11/11 pass; vulnerable 7/11 fail (7 exploit tests: both fixation tests, Secure, SameSite, __Host-, idle and absolute timeouts; the HttpOnly test passes because express-session sets it by default); wrong 2/11 fail (both fixation tests), offline after npm ci
- Dependency freshness
- express 5.3.0 was published on 2026-10-09; it is taken at one day old because it is a security release: it updates qs for CVE-2026-2391, CVE-2026-82417 and CVE-2026-82562, and proxy-addr, which trust proxy relies on, for CVE-2026-90711. express-session 1.19.0 dates from 2026-01-22 and is the latest release. npm audit found 0 vulnerabilities and npm audit signatures verified all 73 packages. Rerun on Node.js 26.11.1, above the 26.5.1 security floor in engines.
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- OWASP Session Management Cheat Sheet (session ID renewal, cookie attributes, timeouts)
- express-session README (regenerate, cookie options, rolling, saveUninitialized, user login example)
- Express behind proxies (trust proxy)
- MDN: Set-Cookie (Secure, HttpOnly, SameSite and the __Host- prefix)
- Node.js test runner: mocking timers and Date (mock.timers)
- OWASP WSTG: Testing for Session Fixation
- CodeQL js/session-fixation (SessionFixation.ql)
Attack patterns: