Session Fixation: The Attack session_regenerate_id() Exists to Stop
The attacker visits your site normally and receives a valid session ID: PHPSESSID=abc123. Nothing is logged in yet. Now the attacker gets the victim to load a URL carrying that same ID — https://shop.example/login?PHPSESSID=abc123 works whenever PHP accepts session IDs from the URL, and an attacker-supplied cookie or a crafted link to a sibling subdomain achieves the same trick. The victim logs in. The session record for abc123 now holds an authenticated user, and the attacker, who already knows the ID, is simply logged in too. No password, no XSS, no stolen cookie — the ID was fixed before authentication ever happened. This is session fixation, and it is the reason login is not just a write to $_SESSION.
PHP enabled this attack surface itself in older days: session.use_trans_sid transparently appended PHPSESSID= to every generated link when a cookie was missing, so a shared URL literally passed someone's session around. It defaults to off now and session.use_only_cookies=1 kills the URL channel entirely — but hand-built links that stash session_id() in a query string, or email campaigns that put IDs in tracking URLs, reintroduce the same hole by hand.
PHP gives you two independent defenses, and you want both:
// php.ini — refuse IDs the server never issued (PHP 5.5.2+)
session.use_strict_mode = 1
// in code — at the moment privileges change
if (loginSucceeds($user, $password)) {
session_regenerate_id(true); // new ID; old record deleted
$_SESSION['user_id'] = $user->id;
}
session.use_strict_mode = 1 makes session_start() reject any ID the server did not generate itself: hand it an uninitialized value and it silently issues a fresh one. That closes the URL-parameter and attacker-set-cookie vectors at the source. session_regenerate_id(true) handles the other half — the ID your legitimate user was already carrying may be known to someone (it sat in a shared URL, a log file, a proxy cache), so at every privilege change you discard it. The true argument deletes the old session record; the default false leaves it behind, which means the attacker's copy of the old ID still works — a mistake that shows up in security reviews constantly.
Timing and scope are where implementations go wrong:
- Regenerate after the credentials verify, not before. Regenerating first then failing the login leaves the user on a known-to-attacker ID anyway if an error path reverts.
- Regenerate on login, on password change, on role elevation — not on every request. A new ID per request breaks every parallel tab the moment the browser presents a stale cookie, and it multiplies session files.
session_regenerate_id()cannot run after output has been sent, same assession_start()— it belongs in the controller before rendering.
session_create_id() (PHP 7.1+) covers the rare case where you must mint an ID for a subsystem yourself — seeding a legacy session store during a migration, for example. It returns a fresh, prefixed ID without opening a session; adopting it still goes through session_id($newId) before start, which is precisely the dangerous call that strict mode exists to neutralize, so treat it as a migration tool and not a request-path primitive.
One caveat for interviews: regeneration protects the login transition. If an attacker steals the cookie after login (network capture, malware), only the cookie flags of the next chapter and server-side revocation of the last chapter limit the damage. Fixation defense is not theft defense — the two get conflated in write-ups all the time.
"What hardens a PHP login against session fixation?" is a near-certain security interview question. The full answer is three moves: use_strict_mode=1, session_regenerate_id(true) inside the success branch, and never accepting session identifiers from URLs (session.use_only_cookies=1, on by default since PHP 5.3).
Idle Windows, Absolute Timeouts, and Killing Every Device's Session
A stolen cookie is valid until something server-side says otherwise. With default settings that means forever — gc_maxlifetime refreshes on every request, so an attacker replaying the cookie keeps the session alive indefinitely. Real hardening gives sessions an end date the attacker cannot push back.
Idle timeout. Track the last activity inside the session and evict when the gap grows too large:
session_start();
$now = time();
if (isset($_SESSION['last_activity']) && $now - $_SESSION['last_activity'] > 1800) {
// 30 minutes idle: behave like a logout
$_SESSION = [];
session_destroy();
// ...redirect to login with a "timed out" message
}
$_SESSION['last_activity'] = $now;
Absolute timeout. The idle check alone cannot stop a busy attacker: any request resets last_activity. An absolute window needs the login moment frozen at authentication:
// at login
$_SESSION['auth_at'] = time();
// on every authenticated request
if (time() - $_SESSION['auth_at'] > 43200) { // 12 hours
$_SESSION = [];
session_destroy();
// ...force re-authentication
}
The two checks answer different questions — "has this session gone quiet?" versus "has this session lived too long?" — and production systems run both. Align the cookie's own lifetime with the idle window so the browser stops replaying a dead ID: session_set_cookie_params(['lifetime' => 1800, ...]).
Revocation. Neither timeout helps the user who clicks "log out everywhere" after a theft. The durable pattern is a per-user version counter stored server-side:
// each request, after loading the user
if (($_SESSION['ver'] ?? null) !== $user->session_version) {
session_destroy(); // stale session: every device logged out
}
// "log out all devices": UPDATE users SET session_version = session_version + 1
Because session data is server-side, you can also inspect and destroy records directly — list the sess_* files for a user's known IDs, or query the Redis/DB handler. That is the asymmetry to state in interviews: you cannot revoke a cookie, but you can revoke what the cookie points at.
Remember-me tokens. "Stay logged in for 30 days" cannot be a long-lived session cookie — that is a theft target with a month of shelf life. The standard design stores only a hash of a random token on the user record, puts user_id:token in the long-lived cookie, looks the record up on presentation, compares with hash_equals(), and rotates the token on every use so a captured cookie dies after one replay. Remember-me and sessions are different mechanisms with different lifetimes; conflating them is how 30-day cookies end up in production.
Login throttling does not belong in the session either. A session-scoped attempt counter resets whenever the attacker changes their session ID — which they control for free. Count failures server-side, per account and per IP, in a table or your framework's rate limiter; keep the session copy only for UX niceties like a remaining-attempts display.
Two fashionable add-ons deserve skepticism, and knowing the trade-off is the interview payoff. Binding sessions to a user-agent or IP breaks legitimate users the moment a mobile network rotates addresses, and it does not stop an attacker behind the same browser profile. Session fingerprinting heuristics raise the bar slightly and cost real false-positive rates. The core four — regenerate at login, HttpOnly+Secure+Lax, idle and absolute timeouts, server-side revocation — cover the actual attack surface; everything else is a supplement.
Quiz
1. Where must session_regenerate_id(true) be called to defend against session fixation, and why that argument?
2. Which attack does the HttpOnly flag on the session cookie stop?
3. You set session.name to '__Host-PHPSESSID'. What does the browser enforce for that cookie?
4. Your app has a 30-minute idle timeout, but an attacker who replays a stolen cookie every minute stays logged in for weeks. What is missing?
Flashcards
Question
What is session fixation, and which three PHP defenses stop it?
Click to reveal answer
Answer
An attacker fixes the victim's session ID before login so the authenticated record is shared. Defenses: session.use_strict_mode=1 (reject uninitialized IDs), session_regenerate_id(true) inside the login success branch (new ID, old record deleted), and session.use_only_cookies=1 so IDs never travel in URLs.
Question
What does session_regenerate_id() with the default argument get wrong?
Click to reveal answer
Answer
The default delete_old_session=false keeps the old session record alive, so an attacker who already knows the pre-login ID can still use it — the fixation window stays open. Passing true deletes the old record alongside issuing the new ID.
Question
What do HttpOnly, Secure, and SameSite each defend against?
Click to reveal answer
Answer
HttpOnly: script access (XSS cannot read document.cookie). Secure: plaintext capture (cookie sent only over HTTPS). SameSite=Lax: cross-site POSTs (baseline CSRF); None is required for cross-site use and must be paired with Secure.
Question
Why does a stolen-cookie attacker survive an idle timeout, and what ends them?
Click to reveal answer
Answer
Any replayed request resets last_activity, so the idle clock never runs out. An absolute timeout fixes auth_at at login and forces re-authentication once time() - auth_at passes the limit — the attacker cannot push that deadline back.
Question
How do you log a user out of every device at once?
Click to reveal answer
Answer
A server-side session_version (or token) column per user: store the value in the session at login, compare it on every request, and destroy the session on mismatch. Incrementing the column — UPDATE users SET session_version = session_version + 1 — invalidates every outstanding session immediately. The cookie cannot be revoked; what it points at can.
Revision Notes
Key Takeaways
- 1. Session fixation authenticates an attacker-known ID; the fix is strict mode plus session_regenerate_id(true) inside the login success branch, and only at privilege changes
- 2. The true argument of session_regenerate_id deletes the old record — the default false leaves the attacker's copy alive, which is the most common incomplete fix
- 3. HttpOnly, Secure, and SameSite defend different channels (XSS reads, network capture, cross-site requests) and all three default weakly in stock php.ini
- 4. Idle timeouts are defeated by replay; absolute timeouts need an auth_at timestamp frozen at login, and both should run together
- 5. You cannot revoke a cookie, but you can revoke what it points at: a per-user session_version comparison logs out every stolen device at once
Interview Tips
- • "Defend a PHP login against session fixation" — strict mode, regenerate_id(true) in the success branch, IDs never in URLs; then say regeneration must happen after credentials verify and not on every request
- • "What does HttpOnly protect? What does it not protect?" — blocks document.cookie exfiltration after XSS; does not stop CSRF or in-page abuse — name SameSite and CSRF tokens as the next layer
- • "How would you implement idle and absolute timeouts?" — last_activity gap check plus a frozen auth_at window; mention aligning session.cookie_lifetime so the browser stops replaying dead IDs
- • "The user was hacked — log out all their sessions" — session_version/token bump server-side, checked per request; explaining that server-side storage is what makes revocation possible is the differentiator
Cheat Sheet
Secure Session Settings
Fixation defense (three moves):
// php.ini
session.use_strict_mode = 1
session.use_only_cookies = 1
// login success branch
session_regenerate_id(true); // true = delete old record
$_SESSION['user_id'] = $user->id;
Hardened cookie:
session_set_cookie_params([
'lifetime' => 0, 'path' => '/', 'secure' => true,
'httponly' => true, 'samesite' => 'Lax',
]);
session_name('__Host-PHPSESSID'); // requires Secure, Path=/, no Domain
Timeouts: idle = check time() - $_SESSION['last_activity']; absolute = freeze auth_at at login and check time() - auth_at; align cookie lifetime with the idle window.
Global logout: per-user session_version stored in the session; compare each request, destroy on mismatch; bump the column to kill all devices.
Flag map: HttpOnly stops XSS reads · Secure stops plaintext capture · SameSite=Lax stops cross-site POSTs · __Host- stops subdomain overwrites.