Session Fixation and Privilege Boundaries

Why the session id must change at login, and the other moments that deserve a new credential.

Session fixation is an attack on the moment authentication happens. The attacker does not steal a session — they plant one before you log in, and inherit it afterwards.

The attack

  1. The attacker obtains a valid (anonymous) session id from your site.
  2. They get the victim's browser to adopt it — through a subdomain XSS, a URL parameter if your framework accepts one, or a cookie set from a sibling domain.
  3. The victim logs in. The server marks that session id as authenticated.
  4. The attacker uses the id they already know, and is now logged in as the victim.

The fix, in one line

Generate a new session id on successful login and discard the old one. Every framework has this call:

  • Laravel — $request->session()->regenerate()
  • Django — request.session.cycle_key()
  • Rails — reset_session
  • PHP — session_regenerate_id(true)
  • Express — issue a new id and delete the old record

The other moments

  • After MFA. The partial session becomes a full one; give it a new id.
  • On privilege elevation. Entering an admin area or a sudo mode.
  • On logout. Destroy, do not reuse.
  • Periodically in long sessions, so a leaked id has a shorter life.

Never accept a session id from the URL

Some frameworks historically supported ?PHPSESSID=… as a fallback. Disable it: it makes fixation trivial and puts session ids into logs, history and referrers.

Bind the session to some context

Recording the user agent, and a coarse IP signal, lets you require re-authentication when they change abruptly. Be careful with full IP pinning — mobile users change address constantly and you will log them out for no reason.

Example

Example · php
<?php
// PHP: true = delete the old session file, not just issue a new id
if (password_verify($password, $user['password_hash'])) {
    session_regenerate_id(true);      // ← the whole defence
    $_SESSION['user_id'] = $user['id'];
    $_SESSION['created_at'] = time();
}

// Also: refuse session ids from the URL
ini_set('session.use_only_cookies', '1');
ini_set('session.use_trans_sid', '0');

When to use it

  • An XSS on a marketing subdomain can no longer be escalated into account takeover, because the session id changes the moment the victim logs in.
  • A partial MFA session is replaced with a new id after the code is verified, so the pre-MFA id cannot be reused.
  • A framework upgrade disables URL-based session ids, removing them from access logs and referrer headers.

More examples

Rotation at every privilege boundary

Sudo mode is worth the small friction on irreversible actions: it means a hijacked session alone is not enough to delete the account.

Example · javascript
// A small helper makes it hard to forget.
async function rotateSession(req, res, userId, extra = {}) {
  const old = req.cookies[COOKIE];
  if (old) await destroySession(old);            // the old id must die
  const sid = await createSession(userId, req, extra);
  res.cookie(COOKIE, sid, COOKIE_OPTIONS);
  return sid;
}

// 1. Login
app.post('/auth/login', loginLimiter, async (req, res) => {
  const user = await verifyCredentials(req.body);
  if (!user) return res.status(401).json({ error: 'invalid_credentials' });

  if (user.totpEnabled) {
    await rotateSession(req, res, user.id, { partial: true });
    return res.json({ mfaRequired: true });
  }
  await rotateSession(req, res, user.id);
  res.json({ user: publicProfile(user) });
});

// 2. After the second factor
app.post('/mfa/verify', partialSession, mfaLimiter, async (req, res) => {
  if (!(await verifyTotp(req.partialUser.id, req.body.code))) {
    return res.status(400).json({ error: 'invalid_code' });
  }
  await rotateSession(req, res, req.partialUser.id, { mfa: true });
  res.status(204).end();
});

// 3. Sudo mode for destructive actions
app.post('/auth/sudo', session, async (req, res) => {
  const user = await db.users.findById(req.user.id);
  if (!(await argon2.verify(user.passwordHash, req.body.password))) {
    return res.status(401).json({ error: 'invalid_credentials' });
  }
  await rotateSession(req, res, user.id, { sudoUntil: Date.now() + 15 * 60e3 });
  res.status(204).end();
});

export const requireSudo = (req, res, next) =>
  req.sessionData?.sudoUntil > Date.now()
    ? next()
    : res.status(403).json({ error: 'sudo_required' });

app.delete('/api/account', session, requireSudo, deleteAccount);

Context binding without logging out mobile users

Stepping up on sensitive routes rather than terminating every session on a network change gives you the security signal without the support burden.

Example · javascript
// Full IP pinning breaks mobile networks constantly. Bind to something coarser.
function contextKey(req) {
  const ip = req.ip ?? '';
  // /24 for IPv4, /48 for IPv6: survives normal roaming, catches a hemisphere jump
  const network = ip.includes(':')
    ? ip.split(':').slice(0, 3).join(':')
    : ip.split('.').slice(0, 3).join('.');

  return createHash('sha256')
    .update(network + '|' + (req.get('user-agent') ?? ''))
    .digest('hex');
}

export async function session(req, res, next) {
  const data = await readSession(req.cookies[COOKIE]);
  if (!data) return res.status(401).json({ error: 'authentication_required' });

  if (data.context && data.context !== contextKey(req)) {
    // Do not necessarily kill it — step up instead, and tell the user.
    if (isSensitive(req.path)) {
      return res.status(401).json({ error: 'reauthentication_required' });
    }
    await notifyNewDevice(data.userId, req);
    await updateSessionContext(req.cookies[COOKIE], contextKey(req));
  }

  req.user = { id: data.userId };
  req.sessionData = data;
  next();
}

Discussion

  • Be the first to comment on this lesson.