Regenerating Session IDs After Login

You logged the user in, but the cookie value never changed — meaning any identifier an attacker planted before login still works. This page is part of the Preventing Session Fixation and Hijacking guide, and it covers the one operation that closes session fixation: minting a brand-new session identifier at every trust transition, copying only the data you need, and destroying the old server-side record cleanly.

Why Regeneration Is Required

Session fixation exploits a server that promotes an anonymous session to an authenticated one in place. The attacker reads or plants a session identifier while the victim is still anonymous, the victim authenticates under that same identifier, and the server simply attaches userId to the existing record. The identifier the attacker already holds is now authenticated.

The fix is structural, not cosmetic: at the moment authentication succeeds, the old identifier must become permanently invalid and a fresh, high-entropy one must take its place. Changing only the cookie’s contents is not enough — for a server-side store, the store key itself must change so the old key resolves to nothing. RFC 6265 governs how the new identifier is carried (HttpOnly, Secure, SameSite), but the spec does not mandate regeneration; that is an application-layer obligation (OWASP ASVS V3.2.1).

When to Regenerate

Regenerate on every transition that raises the trust level of the session — never only at first login:

Trigger Why
Successful password login Closes fixation; the anonymous identifier dies
MFA / second-factor completion The session crosses from password to mfa trust; a pre-MFA identifier must not survive
Privilege elevation (e.g. entering admin/sudo mode) A lower-privilege identifier should not carry into a higher-privilege context
Re-authentication for a sensitive action Confirms freshness and re-anchors the absolute timeout
Account recovery / password reset completion Invalidates any identifier an attacker may have fixed during the reset flow

Do not regenerate on every request — that destroys legitimate concurrent requests, churns the store, and breaks back-button navigation for no security gain.

flowchart LR
    A["Anonymous\nSID=abc"]:::anon -->|password login| B["Regenerate\nSID=xyz1"]:::auth
    B -->|MFA verified| C["Regenerate\nSID=xyz2"]:::auth
    C -->|enter admin| D["Regenerate\nSID=xyz3"]:::admin
    classDef anon  fill:#fffbec,stroke:#d4840a,stroke-width:2px,color:#1a1614
    classDef auth  fill:#eef0ff,stroke:#2c3e8c,stroke-width:2px,color:#1a1614
    classDef admin fill:#fff0ee,stroke:#c0392b,stroke-width:2px,color:#1a1614

The Exact Fix

express-session: regenerate() Does the Right Thing

req.session.regenerate() is purpose-built for this: it generates a new identifier via your genid, writes a fresh store record, deletes the old record, and emits a new Set-Cookie. The catch is data loss — regenerate() gives you an empty session, so you must copy forward anything you want to keep before assigning the authenticated fields.

import type { Request, Response } from "express";

app.post("/login", async (req: Request, res: Response) => {
  const user = await verifyCredentials(req.body.email, req.body.password);
  if (!user) return res.status(401).json({ error: "invalid_credentials" });

  // 1. Snapshot the data worth carrying across the boundary.
  const carry = { locale: req.session.locale, returnTo: req.session.returnTo };

  // 2. regenerate() destroys the old record AND mints a new identifier.
  req.session.regenerate((err) => {
    if (err) return res.status(500).json({ error: "session_error" });

    // 3. Repopulate the now-empty session with authenticated state.
    req.session.userId = user.id;
    req.session.authLevel = user.mfaEnabled ? "password" : "mfa";
    req.session.locale = carry.locale;
    req.session.createdAt = Date.now(); // re-anchor the absolute timeout

    // 4. Persist before responding so the new record exists before redirect.
    req.session.save((saveErr) => {
      if (saveErr) return res.status(500).json({ error: "session_error" });
      res.json({ ok: true, mfaRequired: user.mfaEnabled });
    });
  });
});

On MFA completion, regenerate again and raise authLevel:

app.post("/mfa/verify", async (req: Request, res: Response) => {
  if (req.session.authLevel !== "password") return res.status(401).end();
  const ok = await verifyTotp(req.session.userId!, req.body.code);
  if (!ok) return res.status(401).json({ error: "bad_code" });

  const carry = { userId: req.session.userId, locale: req.session.locale };
  req.session.regenerate((err) => {
    if (err) return res.status(500).json({ error: "session_error" });
    req.session.userId = carry.userId;
    req.session.locale = carry.locale;
    req.session.authLevel = "mfa";
    req.session.createdAt = Date.now();
    req.session.save(() => res.json({ ok: true }));
  });
});

iron-session stores state in an encrypted, signed cookie with no server-side record, so there is no store key to delete. “Regeneration” means rotating an internal sid, re-sealing, and saving — which produces a fresh Set-Cookie. Because the old sealed cookie remains cryptographically valid until it expires, you must back rotation with a server-side check: store the current sid (or a sessionVersion integer) on the user row and reject any cookie whose sid is not the latest.

import { getIronSession } from "iron-session";
import crypto from "node:crypto";

interface SessionData {
  sid: string;
  userId?: string;
  authLevel?: "anonymous" | "password" | "mfa";
}

const opts = {
  password: process.env.IRON_PASSWORD!, // ≥32 chars
  cookieName: "app_session",
  cookieOptions: { httpOnly: true, secure: true, sameSite: "lax" as const, maxAge: 60 * 30 },
};

export async function regenerateAfterLogin(req: Request, res: Response, userId: string) {
  const session = await getIronSession<SessionData>(req, res, opts);
  session.sid = crypto.randomBytes(32).toString("base64url"); // rotate identifier
  session.userId = userId;
  session.authLevel = "password";
  await persistCurrentSid(userId, session.sid); // server-side allowlist of the latest sid
  await session.save(); // emits a new sealed cookie; old one is now stale-by-policy
}

Your request middleware then enforces it:

export async function rejectStaleSession(req: Request, res: Response, next: NextFunction) {
  const session = await getIronSession<SessionData>(req, res, opts);
  if (session.userId) {
    const latest = await loadCurrentSid(session.userId);
    if (session.sid !== latest) {
      session.destroy();
      return res.status(401).json({ error: "session_superseded" });
    }
  }
  next();
}

Destroying the Old Record and Avoiding Races

With a server-side store, regenerate() deletes the old record for you. If you ever roll your own — for example, manually swapping Redis keys — you must DEL the old key, not merely write the new one, or the fixed identifier stays alive.

Two race conditions matter:

  1. Concurrent in-flight requests during login. A second tab may carry the old cookie while login is regenerating. After regeneration completes, that old identifier is dead, so the second request 401s and the client retries with the new cookie. This is correct behavior — accept the brief failure rather than keeping the old identifier alive to avoid it.
  2. Regenerate-then-redirect before save. If you respond or redirect before the new record is persisted, a fast follow-up request can arrive before the store write lands and resolve to no session. Always call save() and respond from its callback (shown above), so the new record provably exists before the client acts on the response.

For load-balanced deployments without sticky sessions, ensure the store (Redis/Postgres) is shared and strongly consistent; a regenerated identifier written to one node must be readable from another on the very next request.

Security Implications

Regeneration is the single control that converts session fixation from “exploitable” to “structurally impossible,” because the identifier an attacker fixed is destroyed the instant authentication succeeds. It also shrinks the hijacking window at each privilege boundary: a cookie captured before MFA cannot be replayed after MFA, since the post-MFA identifier is different. Pair regeneration with HttpOnly and Secure on the reissued cookie (RFC 6265) so the new identifier is not immediately stealable, and with a short absolute timeout re-anchored on createdAt at every regeneration.

Prevention & Monitoring Hooks

  • Assert in tests: a post-login identifier must never equal the pre-login identifier. Make this a CI gate.
  • Log a session_regenerated event with userId, old-id hash, new-id hash, and trigger (login/mfa/elevation) for forensic correlation.
  • Alert on missing regeneration: if login responses ever reuse an inbound sid, fire a high-severity alert — it means the defense regressed.
  • Track superseded-session 401s: a spike in session_superseded rejections can indicate either an attacker replaying old cookies or a deploy that broke save-before-respond ordering.

Frequently Asked Questions

Do I lose all session data when I call regenerate()?

Yes — express-session’s regenerate() hands you an empty session by design, because the whole point is a clean new record under a new identifier. Snapshot the fields you need (locale, cart, returnTo) into a local variable before calling it, then reassign them inside the callback. Never carry forward anything trust-sensitive that should be re-derived from the freshly authenticated user.

Does this apply to JWTs or bearer tokens too?

The fixation mechanic is specific to server-issued session identifiers carried in cookies. With bearer tokens you don’t reuse a pre-auth token; you issue a brand-new access token at login, which is regeneration by another name. The analogous control for refresh tokens is rotation on every use. See Understanding Session vs Token Authentication for where the models diverge.

What about concurrent requests from a second tab during login?

After regeneration, the old identifier is invalid, so an in-flight request from another tab carrying the old cookie will 401 and should transparently retry with the new cookie. This is the correct trade-off — do not keep the old identifier alive to spare that one failed request, because doing so reopens the fixation window.

iron-session has no server store — how do I "destroy" the old cookie?

You can’t force-expire an already-issued sealed cookie, so you rotate an internal sid and keep the latest value server-side (on the user row or in a small allowlist). Request middleware rejects any cookie whose sid is not current. That external check is what gives a stateless cookie revocation semantics; without it, an old sealed cookie stays valid until maxAge.

Should I also regenerate on logout?

On logout you should destroy() the session entirely — delete the store record and clear the cookie — rather than regenerate. Regeneration is for transitions where the user stays authenticated at a new trust level; logout ends the session, so the correct operation is destruction so no identifier survives.