Session Fixation and Hijacking: Attack Walkthroughs and Cookie-Level Defenses

Distinguish session fixation from session hijacking as distinct attack shapes, understand exactly how session_regenerate_id() closes the fixation window, configure Secure/HttpOnly/SameSite cookie attributes correctly, regenerate session identifiers on every privilege change, and implement a logout/timeout flow that actually invalidates a session rather than just clearing the client-side cookie.

A session ID is a bearer credential: whoever presents it IS the logged-in user, as far as the server is concerned, with no further proof required. That property is what makes sessions convenient and also exactly what makes them a favorite attack target -- there are two structurally different ways to end up controlling someone else's session, and they call for two different defenses. Fixation is about controlling the ID BEFORE the victim logs in; hijacking is about stealing a valid ID AFTER they're already logged in. This lesson walks through both attacks concretely, then builds the cookie configuration and lifecycle discipline (regeneration, logout, timeouts) that closes each one off.

Session Fixation: Planting a Known ID Before Login

In a session fixation attack, the attacker doesn't need to steal anything -- they SUPPLY the session ID the victim ends up using, then simply wait. The attack works because many session implementations will happily accept a client-supplied session ID that doesn't correspond to any existing session, silently creating a fresh one under that exact ID rather than rejecting it. If an attacker can get a victim's browser to carry a session ID the attacker already knows -- via a crafted link (https://victim-site.com/?PHPSESSID=attacker-chosen-value, on a misconfigured app that accepts session IDs from the URL), a shared/public computer, or a subdomain that can set cookies for the whole parent domain -- the victim's browser starts a session under that known ID, and it's currently empty and unauthenticated.

The attack completes the moment the victim logs in through that session. Nothing about the login process changes the session ID by default: the same ID that was empty a moment ago is now marked as authenticated, and because the attacker already knows that exact ID, they can present it themselves (in their own browser, via a cookie they set manually) and be treated as the now-logged-in victim -- no credential theft, no XSS, no network sniffing required. The victim did everything right; the vulnerability lived entirely in the fact that the ID never changed across the trust boundary of 'authenticating'.

The fix is single and precise: regenerate the session ID at the moment of any privilege change, most importantly right after a successful login. session_regenerate_id(true) issues a brand-new, unguessable ID and -- critically, with the true argument -- destroys the OLD session's data server-side. Even if the attacker fixed a known ID on the victim, that ID is now worthless the instant the victim logs in, because the server no longer has any session under it.

• Fixation: attacker sets a KNOWN session ID on the victim BEFORE they authenticate, then reuses that same ID after login. • It works because many session backends accept a client-supplied ID for a session that doesn't yet exist, instead of rejecting it. • The fix: session_regenerate_id(true) immediately after login (and every privilege escalation), which also destroys the old session data. • Disabling session.use_trans_sid and never accepting a session ID from a URL/query string closes the most common delivery vector.

Session Hijacking: Stealing an Already-Valid ID

Hijacking is the more familiar attack: the session ID is fine, unguessable, and was never fixed by anyone -- the attacker simply obtains a copy of a legitimate, currently-authenticated victim's ID and presents it as their own. Common theft vectors include: sniffing an unencrypted HTTP connection (the cookie travels in plaintext and anyone on the same network segment, a coffee-shop Wi-Fi, a compromised router, can read it), an XSS vulnerability elsewhere on the site letting injected JavaScript read document.cookie and exfiltrate it, or a server-side leak (the session ID appearing in access logs, error logs, or a referrer header sent to a third-party resource).

Because hijacking doesn't rely on the victim doing anything wrong at login time, regenerating the ID at login does not help here -- the defenses are different and mostly live in the cookie's own attributes and the transport it travels over. Serving the entire site over HTTPS removes the network-sniffing vector outright. The Secure cookie flag makes the browser refuse to ever send the cookie over plain HTTP even if an attacker tricks it into trying, which matters because a single accidental HTTP request (an old bookmark, a misconfigured redirect) is enough to leak a cookie without that flag.

The HttpOnly flag blocks JavaScript from reading the cookie via document.cookie entirely, which closes off the most common XSS-driven theft path -- even if an attacker successfully injects a script into the page, that script simply cannot see the session cookie's value. SameSite (Lax or Strict) restricts when the cookie is attached to cross-site requests at all, which mainly defends against CSRF but also narrows the situations in which a cookie could be exposed to a malicious cross-origin context.

• Hijacking: attacker steals a session ID that ALREADY belongs to a logged-in victim -- via network sniffing, XSS reading document.cookie, or a log leak. • HTTPS everywhere removes the network-sniffing vector; the Secure flag makes the browser refuse to send the cookie over plain HTTP at all. • HttpOnly blocks JavaScript from reading the cookie -- the main defense against XSS-driven theft. • SameSite=Lax/Strict withholds the cookie on most cross-site requests, narrowing cross-origin exposure.

Regenerating on Every Privilege Change, Not Just Login

Login is the obvious moment to regenerate, but it is not the only one. Any point where a session's value to an attacker meaningfully increases is a privilege boundary and deserves the same treatment: a regular user elevating to an admin view (even within the same account, if the app has an 'enter admin mode' step), completing two-factor authentication as a separate step after an initial password check, or changing the account's email/password (where you want to be sure the session driving that change is freshly verified, not one that could have been fixed earlier in the flow).

A useful mental model: session_regenerate_id(true) should run at every point where you would also want to say 'and let's make sure nobody who might have had a copy of the OLD id gets to keep riding along.' Treating it as a one-time 'call it after login and forget it' step misses multi-step authentication flows, where the pre-2FA session and the post-2FA session are functionally different trust levels and deserve different IDs.

• Regenerate on: initial login, completion of 2FA, any explicit privilege elevation, and after a password/email change. • A multi-step login (password, then 2FA) is really two privilege boundaries, not one -- regenerate after each step, not just at the end. • The true argument to session_regenerate_id() is what actually deletes the old session's server-side data -- omitting it leaves the old ID still valid.

Logout and Timeouts: Making 'Logged Out' Actually Mean Something

A logout button that only clears client-side state (removing a cookie via JavaScript, redirecting to a login page) without touching the server-side session is not a real logout -- if the old session ID is ever replayed (from browser history, a cached request, or a copy an attacker already had), it is still fully valid on the server. A correct logout does three things: clears $_SESSION's contents, calls session_destroy() to remove the server-side session record entirely, and explicitly expires the session cookie in the browser by resending it with a past expiration time so a stale cookie doesn't linger client-side either.

Timeouts add a second layer that doesn't depend on the user remembering to click logout at all. An idle timeout (log the user out after N minutes of no activity) is typically implemented by stamping a 'last activity' time into $_SESSION on every request and checking it against the current time at the top of each request -- PHP's own garbage collection (session.gc_maxlifetime) is probabilistic and not a substitute for this explicit check, because it cleans up old session FILES eventually, but doesn't reliably invalidate a specific session at a specific moment for a specific request. An absolute timeout (force re-login after N hours regardless of activity) is a similar stamp, checked against session creation time rather than last-activity time, and matters because an idle timeout alone never expires a session that's kept continuously active -- which is exactly the scenario a stolen, still-being-used session sits in.

• Real logout = clear $_SESSION + session_destroy() + resend the cookie with a past expiry -- not just a client-side redirect. • Idle timeout: stamp last-activity time in $_SESSION, check it on every request, force logout past the threshold. • Absolute timeout: stamp session start time; force re-login after N hours even if the session stayed continuously active. • session.gc_maxlifetime is a probabilistic disk-cleanup setting, not a reliable per-session expiration mechanism -- don't rely on it alone.

Hardened Session Bootstrap and Cookie Configuration

session_set_cookie_params() must run before session_start() -- it configures the cookie session_start() is about to send. 'secure' => true requires the site to actually be served over HTTPS, or the browser will silently refuse to store the cookie at all. 'samesite' => 'Strict' is even tighter than 'Lax' but can break legitimate cross-site navigation into the app (e.g. following an external link into a logged-in page) -- Lax is the common default.

// Configure BEFORE session_start() -- these params control the cookie PHP will send.
session_set_cookie_params([
    'lifetime' => 0,           // expires when the browser closes ("session cookie")
    'path'     => '/',
    'domain'   => '',          // current domain only, no subdomain sharing
    'secure'   => true,        // never sent over plain HTTP
    'httponly' => true,        // JavaScript cannot read it -- blunts XSS theft
    'samesite' => 'Lax',       // withheld on most cross-site requests
]);

session_start();

// Idle-timeout check, run at the top of every authenticated request.
$maxIdleSeconds = 1800; // 30 minutes
if (isset($_SESSION['last_activity']) &&
    (time() - $_SESSION['last_activity']) > $maxIdleSeconds) {
    session_unset();
    session_destroy();
    header('Location: /login?reason=timeout');
    exit;
}
$_SESSION['last_activity'] = time();

Code Examples

session_regenerate_id(true) runs AFTER the credentials check succeeds but BEFORE any authenticated data is written into $_SESSION. The old ID -- which could have been planted on the victim by an attacker before they ever logged in -- now points at nothing, so replaying it is worthless.

Common mistake: Calling session_regenerate_id() with no argument (or with false), which issues a new ID but leaves the OLD session's data intact on disk under the old ID -- an attacker who fixed that old ID can, in some server configurations, still read stale data from it.

This clears the in-memory $_SESSION array, tells the browser to drop the cookie immediately by resending it with a timestamp in the past, and destroys the server-side record with session_destroy(). All three steps matter: skipping the setcookie() call leaves a dead cookie in the browser, and skipping session_destroy() leaves the data recoverable server-side if the old ID is ever replayed.

Common mistake: Only doing unset($_SESSION['user_id']) on logout -- the session itself (and its ID) stays fully alive on the server; anything else stored in $_SESSION survives, and the same ID keeps working for a re-login without any fresh authentication check.

function login(string $username, string $password, PDO $db): bool
{
    $stmt = $db->prepare('SELECT id, password_hash FROM users WHERE username = ?');
    $stmt->execute([$username]);
    $user = $stmt->fetch(PDO::FETCH_ASSOC);

    if ($user === false || !password_verify($password, $user['password_hash'])) {
        return false;
    }

    // Whatever session ID existed BEFORE this point (possibly attacker-fixed)
    // is destroyed here, and a fresh, unguessable one takes its place.
    session_regenerate_id(true);

    $_SESSION['user_id'] = $user['id'];
    $_SESSION['authenticated_at'] = time();
    $_SESSION['last_activity'] = time();

    return true;
}
function logout(): void
{
    $_SESSION = [];

    // Explicitly expire the cookie client-side (session_destroy() alone
    // does not remove the cookie already sitting in the browser).
    if (ini_get('session.use_cookies')) {
        $params = session_get_cookie_params();
        setcookie(
            session_name(),
            '',
            time() - 42000,
            $params['path'],
            $params['domain'],
            $params['secure'],
            $params['httponly']
        );
    }

    session_destroy(); // removes the server-side session record

    header('Location: /login?reason=logout');
    exit;
}

Fixation vs. Hijacking -- Two Different Attack Timings

Fixation is defeated by changing the ID at a trust boundary; hijacking is defeated by making the ID harder to steal in the first place.

FIXATION (attacker acts BEFORE login)          HIJACKING (attacker acts AFTER login)
  --------------------------------------          --------------------------------------
  1. Attacker picks ID "X"                        1. Victim logs in normally, gets ID "Y"
  2. Attacker gets victim's browser                2. Attacker steals "Y" via:
     to carry ID "X" (crafted link,                    - unencrypted HTTP sniff
     shared device)                                    - XSS reading document.cookie
  3. Victim logs in -- ID "X" is now                    - a leaked log line
     authenticated                                 3. Attacker presents "Y" directly
  4. Attacker presents ID "X" too --                   -- fully valid, no login needed
     already authenticated as victim
  --------------------------------------          --------------------------------------
  FIX: regenerate the ID AT login                 FIX: Secure + HttpOnly + SameSite
  (session_regenerate_id(true))                    cookies, HTTPS everywhere, no XSS

Important Rules

Call session_regenerate_id(true) immediately after login and after every privilege escalation, never omit the true argument.

Set Secure, HttpOnly, and SameSite on the session cookie via session_set_cookie_params() before session_start().

Serve the entire application over HTTPS -- Secure-flagged cookies are meaningless if the site is reachable over plain HTTP at all.

A real logout clears $_SESSION, calls session_destroy(), and explicitly re-sends the cookie with a past expiry.

Track both an idle timeout (last-activity stamp) and an absolute timeout (session-start stamp) -- they cover different attack windows.

Why Regeneration Timing Trips Up Real Codebases

A recurring real-world mistake is regenerating the session ID somewhere in a shared 'bootstrap' file that runs on every request, rather than specifically at the login/privilege-escalation code path -- which either does nothing useful (regenerating an already-authenticated session on every page load, wasted work) or, worse, regenerates BEFORE the credentials check, which does nothing to stop fixation since the fix only matters at the exact moment trust level changes. The discipline that actually works is narrow and deliberate: regenerate exactly at each point where the session's authority increases, and nowhere else.

session_start(); $_SESSION['user_id'] = $user['id']; // no regeneration at all session_start(); if (loginSucceeds()) { session_regenerate_id(true); $_SESSION['user_id'] = $user['id']; }

Without regeneration, whatever session ID existed before login (which could have been fixed by an attacker) stays valid after login, handing them an authenticated session for free.

setcookie(session_name(), '', time() - 1); // secure/httponly omitted on logout setcookie(session_name(), '', time() - 1, $params['path'], $params['domain'], $params['secure'], $params['httponly']);

A setcookie() call that overwrites a cookie must match its original path/domain/secure/httponly attributes or some browsers will treat it as a different cookie and fail to actually clear the original one.

What distinguishes session fixation from session hijacking? A. Fixation only affects mobile browsers; hijacking only affects desktop browsers B. Fixation plants a known ID on the victim before login; hijacking steals an already-valid ID after login C. They are two names for the exact same attack D. Fixation requires HTTPS to work; hijacking requires plain HTTP

B. Fixation plants a known ID on the victim before login; hijacking steals an already-valid ID after login — Fixation is about controlling the ID before the trust boundary (login); hijacking is about stealing a legitimate ID after that boundary has already been crossed. They require different defenses.

What does the boolean true argument to session_regenerate_id(true) actually do? A. It forces the new session ID to be exactly 32 characters long B. It deletes the OLD session's data on the server, in addition to issuing a new ID C. It disables cookies and switches to URL-based session IDs D. It extends the session's expiration time by one hour

B. It deletes the OLD session's data on the server, in addition to issuing a new ID — Without the true argument, a new ID is issued but the old session's server-side data remains intact and could still be accessed under the old ID in some configurations. true removes that old data entirely.

Which cookie attribute prevents JavaScript from reading the session cookie via document.cookie? A. Secure B. SameSite C. HttpOnly D. Path

C. HttpOnly — HttpOnly instructs the browser to withhold the cookie from any script-level access, which is the main defense against an XSS payload exfiltrating the session ID.

Why is clearing a session cookie on the client alone not a sufficient logout? A. Cookies cannot be cleared by JavaScript at all B. The server-side session record still exists and remains fully valid if the old ID is ever replayed C. It causes the browser to crash D. It is sufficient, and no server-side step is needed

B. The server-side session record still exists and remains fully valid if the old ID is ever replayed — If session_destroy() is never called, the session data still lives on the server under its ID -- anyone who still has that ID (from history, a cache, or a prior theft) can keep using it as if the user never logged out.

A login flow currently does: check password, then check a separate 2FA code, then set $_SESSION['user_id']. Identify every point in this flow where session_regenerate_id(true) should be called, and explain why calling it only once, after the 2FA check, is not quite the strongest possible design.

Regenerate once right after the password check succeeds (before storing anything indicating 'password verified, awaiting 2FA' in $_SESSION), and again right after the 2FA code is confirmed. Calling it only once after 2FA leaves the pre-2FA, password-verified intermediate session running under whatever ID existed before -- which could have been fixed by an attacker -- for the whole duration of the 2FA step; if that intermediate state carries any useful information or partial trust, an attacker holding the pre-fixed ID could observe or interfere with it during that window.

Think about what the session looks like in between the password check succeeding and the 2FA check completing -- is that an interesting trust level to an attacker, even though the user isn't 'fully' logged in yet?