Session fixation attacks: why regenerating session IDs matters

Session fixation is an older attack class that remains relevant whenever server-side sessions are used without careful session ID lifecycle management. The attack is conceptually simple: trick the victim into using a session ID that the attacker already knows, then wait for the victim to authenticate. After authentication, the attacker uses the known session ID to access the victim's authenticated session. The defense is equally simple — regenerate the session ID upon authentication — but it is still missing from a surprising number of codebases.

How the attack works

The attack requires that your application accept a session ID provided by the client before authentication. This is common in cookie-based session management where the session cookie is set at first visit (to store cart data, preferences, or CSRF tokens) and retained through the login flow.

  1. The attacker visits your site and obtains a valid session ID (e.g., sid=ABC123) from the session cookie.
  2. The attacker tricks the victim into making a request with that same session ID — through a crafted link (https://yourapp.com/login?sessionid=ABC123 in a URL parameter-based system), or by injecting a Set-Cookie header via an open redirect or a subdomain XSS.
  3. The victim follows the link, logs in, and your server upgrades session ABC123 to an authenticated state.
  4. The attacker, who has known ABC123 from the start, now sends requests with that session ID and is authenticated as the victim.

The severity depends on what the session allows. In most SaaS applications, the attacker gets full access to the victim's account.

The fix: regenerate session ID on authentication

The mitigation is to generate a completely new session ID immediately upon successful authentication, copy any pre-authentication session data (CSRF tokens, redirect URL) to the new session, and invalidate the old ID.

// Express.js with express-session: regenerate on login
app.post('/auth/login', async (req, res) => {
  const { email, password } = req.body;
  const user = await validateCredentials(email, password);

  if (!user) {
    return res.status(401).json({ error: 'invalid_credentials' });
  }

  // Save any pre-auth data we need to carry over
  const preAuthData = {
    returnTo: req.session.returnTo,
    csrfToken: req.session.csrfToken,
  };

  // CRITICAL: Regenerate session ID before setting authenticated state
  await new Promise((resolve, reject) => {
    req.session.regenerate(err => err ? reject(err) : resolve());
  });

  // New session ID is now active; restore pre-auth data
  req.session.returnTo = preAuthData.returnTo;
  req.session.csrfToken = preAuthData.csrfToken;

  // Set authenticated state on the NEW session
  req.session.userId = user.id;
  req.session.authenticatedAt = new Date().toISOString();

  await new Promise((resolve, reject) => {
    req.session.save(err => err ? reject(err) : resolve());
  });

  res.json({ success: true });
});
// Redis session store: manual regeneration if not using express-session
async function regenerateSession(oldSessionId, userId, carryOverData) {
  const newSessionId = crypto.randomBytes(32).toString('base64url');

  // Create new session with authenticated state
  await redis.setex(
    `session:${newSessionId}`,
    SESSION_TTL,
    JSON.stringify({
      userId,
      authenticatedAt: Date.now(),
      ...carryOverData,
    })
  );

  // Immediately delete the old session — do not leave it active
  await redis.del(`session:${oldSessionId}`);

  return newSessionId;
}
Regenerating the session ID is not just good practice — it is required by OWASP's Session Management Cheat Sheet, PCI DSS requirement 8.8.6, and is expected by penetration testers doing compliance assessments. If your login flow does not regenerate the session ID, it will be flagged in every security audit.

Cookie attributes that harden session security

Beyond session ID regeneration, the session cookie's attributes significantly affect the attack surface:

// Secure session cookie configuration
res.cookie('sid', sessionId, {
  httpOnly: true,      // prevents JavaScript access (mitigates XSS-based session theft)
  secure: true,        // HTTPS only — prevents transmission over HTTP (prevents interception)
  sameSite: 'strict',  // prevents cross-site request forgery via cookie
                       // Use 'lax' if you need cookies on top-level navigations from other sites
  path: '/',
  maxAge: 30 * 24 * 60 * 60 * 1000,  // 30 days in milliseconds
  // domain: omitted — scopes to current domain only (prevents subdomain access)
});

The SameSite=Strict attribute is the most important for session fixation via cross-site injection: it prevents the session cookie from being sent in any cross-site request, including navigations triggered by malicious links. The trade-off is that users who click a link to your app from an external site will not be logged in on the first request and may need to authenticate again. SameSite=Lax is a reasonable middle ground: it allows the cookie on top-level navigations (clicking a link) but blocks it on sub-resource requests and form POSTs from other sites.

URL parameter session IDs: never do this

Accepting session IDs in URL parameters (/app?sessionid=XYZ) makes session fixation trivial — an attacker constructs a URL with a known session ID and shares it. URL parameters are logged in web server access logs, appear in the browser's address bar, are included in the Referer header when navigating to other sites, and can be stored in browser history. Session identifiers must only be transmitted in cookies with appropriate attributes, never in URLs.

Detecting fixation attempts

// Detect anomalous session characteristics suggesting fixation
async function validateSessionIntegrity(sessionId, req) {
  const session = await getSession(sessionId);
  if (!session) return false;

  // Bind session to IP at creation (optional — breaks legitimate IP changes)
  // More practical: log when IP changes and flag for review
  if (session.createdFromIp && session.createdFromIp !== req.ip) {
    await recordSecurityEvent(session.userId, 'session_ip_change', {
      originalIp: session.createdFromIp,
      currentIp: req.ip,
      sessionId,
    });
    // Don't block on IP change alone — legitimate with mobile/VPN
    // But combine with other signals
  }

  // Detect if session was created very recently and immediately used (fast use after creation)
  // A fixation attack often creates a session then immediately sends the fixed ID to the victim
  const sessionAge = Date.now() - session.createdAt;
  if (!session.userId && sessionAge < 1000) {
    // Pre-auth session used within 1 second of creation is suspicious
    // (could be an attacker pre-creating sessions to fix)
  }

  return true;
}

Session fixation is easy to prevent at implementation time and expensive to fix retrospectively if your session management is deeply embedded. The two-line fix — call session.regenerate() immediately on successful login — is the most important single change you can make to improve session security in a legacy application. Combined with HttpOnly; Secure; SameSite=Strict cookie attributes, it eliminates the attack vector entirely.

← Back to blog Try Bastionary free →