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
- The attacker obtains a valid (anonymous) session id from your site.
- 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.
- The victim logs in. The server marks that session id as authenticated.
- 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
<?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.
// 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.
// 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