Logout, Expiry and Session Rotation
Ending a session properly, and the three moments when a session id must change.
Logout looks trivial and is routinely implemented wrong. Clearing the cookie only tells that one browser to forget the id. If the session record still exists, anyone holding a copy — from a log, a shared computer, an intercepted request — remains logged in.
Logout, correctly
- Delete the server-side session record first. This is the step that actually revokes access.
- Clear the cookie with identical
Path,Domain,SecureandSameSiteattributes — a mismatch means the browser keeps the original. - Return 204 and let the client drop its local state.
Log out everywhere
Store sessions under a per-user index so you can delete them all: a Redis set of session keys per user, or a user_id column you can index. This is what powers the "active sessions" screen and the "sign out of all devices" button — and it must run automatically on password change.
Rotate the id at these three moments
- On login. Prevents session fixation: an attacker who plants a known id in the victim's browser before login otherwise ends up sharing the authenticated session.
- On privilege elevation. Entering an admin area or completing MFA should mint a new id.
- Periodically in long sessions, so a leaked id has a shorter useful life.
Two clocks, again
Idle timeout logs out someone who walked away. Absolute timeout caps a session that is being kept warm artificially. Both belong in the session record, not in the cookie — a cookie's Max-Age is a browser hint that an attacker with the raw id can ignore entirely.
Example
app.post('/logout', session, async (req, res) => {
await destroySession(req.sid); // 1. revoke, server-side
res.clearCookie('__Host-sid', { // 2. same attributes as when set
path: '/', secure: true, sameSite: 'lax',
});
res.status(204).end(); // 3. client clears its state
});
app.post('/logout-all', session, async (req, res) => {
await destroyAllSessionsFor(req.user.id); // every device
res.clearCookie('__Host-sid', { path: '/', secure: true, sameSite: 'lax' });
res.status(204).end();
});When to use it
- A user changes their password after a phishing scare, and every other session is destroyed automatically so the attacker is evicted immediately.
- An 'active sessions' screen lists device, IP and last-seen for each session with a revoke button, backed by a per-user session index.
- A shared-computer logout works properly because the server-side record is deleted, not merely the browser cookie.
More examples
Per-user session index and full revocation
The per-user set is small, cheap, and turns three separate features — logout everywhere, active-session list, and password-change eviction — into one primitive.
import { randomBytes, createHash } from 'crypto';
const key = (sid) => 'sess:' + createHash('sha256').update(sid).digest('hex');
const userIndex = (userId) => `user-sessions:${userId}`;
export async function createSession(userId, req) {
const sid = randomBytes(32).toString('base64url');
await redis.multi()
.set(key(sid), JSON.stringify({
userId, createdAt: Date.now(), ip: req.ip, ua: req.get('user-agent'),
}), 'EX', IDLE_S)
.sadd(userIndex(userId), key(sid)) // so we can find them all later
.expire(userIndex(userId), ABSOLUTE_S)
.exec();
return sid;
}
export async function destroySession(sid) {
const k = key(sid);
const raw = await redis.get(k);
if (raw) await redis.srem(userIndex(JSON.parse(raw).userId), k);
await redis.del(k);
}
export async function destroyAllSessionsFor(userId) {
const keys = await redis.smembers(userIndex(userId));
if (keys.length) await redis.del(...keys);
await redis.del(userIndex(userId));
}
// Password change MUST trigger this — otherwise the attacker who knew the old
// password keeps their session while the victim thinks they locked them out.
export async function changePassword(userId, newPassword, keepSid) {
await db.users.update(userId, { passwordHash: await argon2.hash(newPassword) });
await destroyAllSessionsFor(userId);
return createSession(userId, { ip: null, get: () => null }); // re-issue for this device
}Rotation on login and on elevation
In frameworks this is one call — session()->regenerate() in Laravel, request.session.cycle_key() in Django — but it has to actually be called on every privilege change.
// Session fixation: attacker sets sid=KNOWN in the victim's browser (via a
// subdomain XSS or a crafted link), victim logs in, attacker reuses sid=KNOWN.
// The fix is to never keep a pre-login id.
app.post('/login', loginLimiter, async (req, res) => {
const user = await verifyCredentials(req.body);
if (!user) return res.status(401).json({ error: 'invalid_credentials' });
const old = req.cookies['__Host-sid'];
if (old) await destroySession(old); // discard whatever was there
const sid = await createSession(user.id, req); // brand-new id
res.cookie('__Host-sid', sid, COOKIE_OPTIONS);
res.json({ user: publicProfile(user) });
});
// Same rule after a second factor is verified
app.post('/mfa/verify', session, async (req, res) => {
if (!verifyTotp(req.user.id, req.body.code)) {
return res.status(401).json({ error: 'invalid_code' });
}
await destroySession(req.sid);
const sid = await createSession(req.user.id, req, { mfa: true });
res.cookie('__Host-sid', sid, COOKIE_OPTIONS);
res.status(204).end();
});
Discussion