Regenerating Session IDs in Express After Login
An Express application that authenticates users by setting req.session.userId on the existing session — without rotating the identifier — is textbook vulnerable to session fixation. The connect.sid cookie the browser carried as an anonymous visitor is the same one it carries once logged in, so any attacker who planted that identifier beforehand rides the authenticated session. This guide fixes the defect end to end in an Express + express-session app backed by Redis: reproduce the bug, wire req.session.regenerate() into the login handler, harden the cookie and store, and lock the fix in place with an integration test that fails if the identifier ever stops rotating. It is a hands-on companion to Session Fixation Prevention within Secure Authentication & Session Architecture. Because the rotated cookie is also your primary CSRF-relevant credential, pair this with CSRF defense.
Prerequisites
- Node.js 18+ with an Express 4/5 app using
express-session - A running Redis instance and the
connect-redisv7+ store - TypeScript 5+ and
supertest+vitest(orjest) for the integration test - HTTPS in production so the
Securecookie flag does not drop the cookie
Expected Outcomes
- The
connect.sidvalue differs before and after login - The pre-authentication Redis key is deleted, not just abandoned
- Only whitelisted state (for example
returnTo) survives the transition - A committed test fails the build if regeneration is ever removed
Step 1: Reproduce the Fixation Bug
Start from the vulnerable handler so the failure mode is concrete. This code authenticates by mutating the existing session — the identifier never changes.
// VULNERABLE — do not ship. Same session ID before and after login.
app.post('/login', async (req, res) => {
const user = await verifyCredentials(req.body.email, req.body.password);
if (!user) return res.status(401).json({ error: 'invalid_credentials' });
req.session.userId = user.id; // in-place mutation: SID unchanged
res.json({ ok: true });
});
Demonstrate the attack with curl. Grab an anonymous cookie, then log in reusing it, and observe that the same identifier is now authenticated.
# 1. Obtain an anonymous session cookie (the value an attacker would fix)
curl -s -c jar.txt http://localhost:3000/ > /dev/null
grep connect.sid jar.txt # note the SID value, e.g. s%3Aabc123...
# 2. Log in REUSING that same cookie jar
curl -s -b jar.txt -c jar.txt -X POST http://localhost:3000/login \
-H 'Content-Type: application/json' \
-d '{"email":"[email protected]","password":"correct-horse"}'
# 3. The SID in jar.txt is UNCHANGED — the fixed identifier is now logged in
grep connect.sid jar.txt # same value as step 1 → fixation confirmed
The sequence below shows exactly why the fixed identifier survives, and what the fix changes.
Step 2: Regenerate the Session on Login
Replace the in-place mutation with req.session.regenerate(). Capture only the state you deliberately want to carry across the boundary into local variables first, then repopulate the fresh session inside the callback and persist it with save() before responding.
import type { Request, Response, NextFunction } from 'express';
app.post('/login', async (req: Request, res: Response, next: NextFunction) => {
const user = await verifyCredentials(req.body.email, req.body.password);
if (!user) return res.status(401).json({ error: 'invalid_credentials' });
// Whitelist the state that should outlive the anonymous session
const returnTo = req.session.returnTo ?? '/dashboard';
req.session.regenerate((regenErr) => { // new SID + old Redis key dropped
if (regenErr) return next(regenErr);
req.session.userId = user.id; // authenticated state on the fresh session
req.session.returnTo = returnTo;
req.session.loginAt = Date.now();
req.session.save((saveErr) => { // flush to Redis before responding
if (saveErr) return next(saveErr);
res.json({ ok: true, returnTo });
});
});
});
Two rules make this reliable. First, always save() inside the callback before you send the response — otherwise a fast client can race the store write and land on a node that has not yet seen the new record. Second, resist the temptation to Object.assign(newSession, oldSession): copying the whole object drags back the very pre-auth state you are trying to abandon. Copy named fields only.
If you use Passport, req.login() regenerates by default in current releases, but call it and then assert the behaviour — do not assume:
app.post('/login', authenticate('local'), (req, res, next) => {
// req.login regenerates unless keepSessionInfo:true is passed. Verify with a test.
req.session.save((err) => (err ? next(err) : res.json({ ok: true })));
});
Step 3: Harden the Cookie and Redis Store
Regeneration closes the fixation hole; cookie hardening keeps the rotated identifier from being planted or stolen in the first place. Configure express-session with a connect-redis store, the __Host- cookie prefix, and the full flag set.
import session from 'express-session';
import { RedisStore } from 'connect-redis';
import { createClient } from 'redis';
const redisClient = createClient({ url: process.env.REDIS_URL });
await redisClient.connect();
app.set('trust proxy', 1); // required so Secure cookies work behind a TLS-terminating proxy
app.use(session({
name: '__Host-sid', // pins cookie to exact host; requires Secure + Path=/
store: new RedisStore({ client: redisClient, prefix: 'sess:', ttl: 60 * 30 }),
secret: process.env.SESSION_SECRET!,
resave: false,
saveUninitialized: false, // no Redis record until real state exists
rolling: true, // refresh idle TTL on each authenticated request
cookie: {
secure: true,
httpOnly: true,
sameSite: 'lax',
path: '/',
maxAge: 1000 * 60 * 30, // 30-minute idle window, matched to the store TTL
},
}));
Setting saveUninitialized: false is a fixation control in its own right: it means anonymous visitors never get a persisted Redis record, so there is no long-lived pre-auth identifier for an attacker to fix. The __Host- prefix forbids a Domain attribute, blocking the cross-subdomain Set-Cookie planting vector covered in the parent guide.
Step 4: Add an Integration Test Asserting the ID Changed
Lock the fix in place. This supertest test drives an anonymous request, logs in on the same agent, and asserts the __Host-sid value rotated — the exact property fixation violates.
import { describe, it, expect, beforeAll } from 'vitest';
import request from 'supertest';
import { app } from '../src/app';
function sidOf(setCookie?: string[]): string | undefined {
return setCookie?.find((c) => c.startsWith('__Host-sid='))?.split(';')[0];
}
describe('session fixation: login rotates the identifier', () => {
it('issues a different session cookie after authentication', async () => {
const agent = request.agent(app);
const anon = await agent.get('/'); // establishes an anonymous cookie
const preSid = sidOf(anon.headers['set-cookie'] as string[]);
const login = await agent
.post('/login')
.send({ email: '[email protected]', password: 'correct-horse' });
const postSid = sidOf(login.headers['set-cookie'] as string[]);
expect(login.status).toBe(200);
expect(preSid).toBeDefined();
expect(postSid).toBeDefined();
expect(postSid).not.toEqual(preSid); // regression fails here if regenerate() is removed
});
});
Verification
Confirm the fix from three angles — the cookie rotates, the old Redis key is gone, and the test guards it.
# 1. Re-run the curl reproduction from Step 1; the SID must now CHANGE across login
curl -s -c jar.txt http://localhost:3000/ > /dev/null
PRE=$(grep -o 'Host-sid[^ ]*' jar.txt)
curl -s -b jar.txt -c jar.txt -X POST http://localhost:3000/login \
-H 'Content-Type: application/json' \
-d '{"email":"[email protected]","password":"correct-horse"}' > /dev/null
POST=$(grep -o 'Host-sid[^ ]*' jar.txt)
test "$PRE" != "$POST" && echo "PASS: session ID rotated" || echo "FAIL: fixation present"
# 2. Confirm the pre-auth key was deleted from Redis (no orphaned sess:* for the old ID)
redis-cli --scan --pattern 'sess:*' | wc -l
# 3. Run the regression test
npm test -- session-fixation
Expected: step 1 prints PASS, step 2 shows only active authenticated sessions (the anonymous key is gone because saveUninitialized:false never wrote one), and the test suite is green.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| New cookie sometimes equals the old one under load | Response sent before req.session.save() completed; a concurrent request wrote first |
Move the res.json() call inside the save() callback so the store write finishes before responding |
req.session is undefined inside the regenerate callback |
Referencing the outer session variable after regeneration replaced it | Use req.session directly inside the callback; never cache the pre-regeneration object |
| Passport still reuses the session ID | req.login() called with { keepSessionInfo: true }, or an old Passport version |
Upgrade Passport, drop keepSessionInfo, and keep the Step 4 test as the guard |
Flash messages / returnTo vanish after login |
Values lived only on the destroyed pre-auth session | Copy the named fields to locals before regenerate() and reassign them in the callback |
| Cookie not set at all in production | Secure flag dropped because the proxy hop looks like plain HTTP |
Set app.set('trust proxy', 1) and ensure TLS terminates correctly upstream |
Common Implementation Mistakes
Frequently Asked Questions
Why does editing req.session in place fail to prevent fixation?
Mutating req.session — for example assigning req.session.userId = user.id — keeps the same underlying session identifier and the same Redis key. You are marking the existing record as authenticated rather than creating a new one, so the pre-login identifier an attacker planted is still valid and now belongs to a logged-in user. Only req.session.regenerate() mints a new identifier and drops the old record, which is what actually breaks the attack.
How do I keep flash messages or returnTo across regeneration?
Copy the specific values into local variables before calling regenerate(), then reassign them onto the fresh session inside the regenerate callback and call save(). In the Step 2 handler, returnTo is captured up front and restored after rotation. Do not try to preserve the whole session object with Object.assign — that reintroduces exactly the pre-auth state you are discarding and can carry a stale, attacker-influenced value back into the authenticated session.
Does Passport regenerate the session for me?
Passport’s req.login() regenerates the session by default in recent versions, which is why upgrading Passport is itself a fixation fix. However, the keepSessionInfo: true option and some custom verify callbacks suppress regeneration, and older releases did not rotate at all. Never assume — keep the Step 4 integration test in your suite so that the build fails if the __Host-sid cookie stops changing across authentication for any reason.
Related
- Session Fixation Prevention — the parent guide covering the full fixation defense stack
- Secure Authentication & Session Architecture — index for the authentication and session control set
- Cross-Site Request Forgery (CSRF) Defense — protecting the rotated session cookie against forged authenticated requests