DeepConcepts

Security / security / browser / sessions

Rotating the Session ID Protects the Login, Not the Session

The misconception

That calling session_regenerate_id() — or cycle_key(), or reset_session() — at login is what fixes session fixation. It removes exactly the attacks whose whole method is planting an identifier before the victim signs in. It does nothing about the old record, which PHP keeps by default because delete_old_session defaults to false and deleting it immediately breaks concurrent requests. It does nothing about privilege changes that are not a login, such as a second-factor step-up. It does nothing about a password change, because with no server-side registry and no password-bound session the change writes one row in the users table and ends no session at all. And it does nothing on any code path that does not run the sign-in handler, such as a remember-me cookie restoring a session onto whatever identifier the browser presented — which the server will happily adopt, because session.use_strict_mode has defaulted to 0 since the day it was added.

15 min

Session fixation is the attack where someone else chooses your session identifier before you sign in, then uses it afterwards. The published fix is one line: replace the identifier at login. Every framework has it — session_regenerate_id(), cycle_key(), reset_session(), changeSessionId() — and calling it does remove the attack it is named after. The trouble is what people conclude from that.

A session identifier is a bearer credential. Whoever holds the string is the user, for as long as the server will accept it. Replacing it at sign-in changes which string that is at one instant. It says nothing about who can read the new one, who can overwrite it, whether the old one still works, what happens at the next privilege change, or what a password change does to a session the server has no record of.

The panel below is one application. Nine paths lead to an attacker holding a session the server treats as the victim, and one row is a legitimate request that a defence can break. Each path is scored against your configuration, and the panel prints which specific rule decided it.

It starts where most applications already are: rotation at login with PHP's default argument, PHP's default cookie attributes, PHP's default session.use_strict_mode, and no server-side record of which sessions exist. That is the recommended fix, correctly applied, and six of the nine paths still work. Set rotation to never and the number goes to nine — so the rotation was doing real work. It was doing exactly three paths' worth.

PHP ships this at 0. It is one line of php.ini and no code change.

paths that still end with the attacker holding the session
paths closed
legitimate requests broken
rotation closes
Nine ways to end up holding someone else's session

the attacker ends up holding the victim's session · a legitimate request broken by a defence · closed. Cookie behaviour follows the storage and retrieval rules in the cookie specification; browser-specific partitioning is not modelled.

Work down the controls one at a time and watch which row each closes. Only one setting closes the password-change row, only one closes the step-up row, and the very last door to shut is a php.ini value that has defaulted to 0 since it was introduced. Nothing here is exotic. Every one of these settings is a default somebody chose, and the defaults do not compose into a safe session.

Why the old record is kept, and why that is not a bug

The signature is session_regenerate_id(bool $delete_old_session = false). False. If you have ever written the call without an argument — and almost every tutorial does — the previous session file is still on disk with the data it held at the moment of the call, and the previous identifier still resolves to it.

Whether that record is authenticated comes down to the order of two lines. Rotate first and then write $_SESSION['user_id'], and the old record holds anonymous data. Write the user id first and then rotate, and PHP copies an authenticated session into the new identifier and leaves an authenticated session behind at the old one. Row F4 is that second ordering. It is a two-line diff and it is invisible in review, because both versions "regenerate the session id at login".

The default is not carelessness. PHP's manual is explicit about what deleting immediately costs: "You should not destroy old session data immediately, but should use destroy time-stamp and control access to old session ID. Otherwise, concurrent access to page may result in inconsistent state, or you may have lost session, or it may cause client (browser) side race condition and may create many session ID needlessly." The same page carries a warning that session_regenerate_id "does not handle an unstable network well, e.g. Mobile and WiFi network. Therefore, you may experience a lost session."

Row L1 is that failure, and it is not theoretical. Magento's issue 12362 — "Concurrent (quick reload) requests on checkout cause cart to empty" — ran to seventy-four comments about exactly this: a page with a request already in flight when the identifier changed, arriving at a store that no longer recognised it, and a checkout emptying as a result. Set the rotation control to session_regenerate_id(true) and watch L1 turn amber at the same moment F4 closes. Both things are true at once.

The way out is the one PHP's manual points at and almost nobody implements: keep the old record, but mark it with a destruction timestamp and a forwarding pointer to the new identifier. A request arriving on the old one inside a short grace window gets the new cookie and continues; a request arriving after the window is a signal that someone else has the old string, which is the "session hijack attack detection" the manual says immediate deletion throws away. Frameworks that rename the record rather than copying it — Servlet 3.1's changeSessionId(), Django's cycle_key(), which creates the new key and then calls self.delete(key) on the old one — take the other branch and accept the race.

The plant vectors, and what actually blocks them

Rows F1, F2 and F3 are the three paths rotation closes. It is worth knowing what they are, because they are also the paths that the cookie's own name can close without any code at all — and unlike rotation, the cookie closes them at both ends of the session rather than only at sign-in.

A sibling host writes your cookie. The cookie specification is blunt about this in §8.6, headed "Weak Integrity": "Cookies do not provide integrity guarantees for sibling domains (and their subdomains) … The foo.site.example server can set a cookie with a Domain attribute of 'site.example' (possibly overwriting an existing 'site.example' cookie set by bar.site.example) … In the worst case, bar.site.example will be unable to distinguish this cookie from a cookie it set itself." Any host on your registrable domain — a status page, a docs site, a user-content subdomain, a marketing microsite running someone else's template — can set your session cookie. That is the same property that makes <code>SameSite=Strict</code> useless against a subdomain, seen from the writing side instead of the reading side.

An active network attacker writes your cookie. The same section: "An active network attacker can also inject cookies into the Cookie header field sent to https://site.example/ by impersonating a response from http://site.example/ … The HTTPS server at site.example will be unable to distinguish these cookies from cookies that it set itself." One plain HTTP request to your hostname is all it takes, and browsers make plenty of them.

Secure alone does not close that second one, and the panel reflects it. Step 16 of the cookie storage model refuses a non-secure cookie that would overwrite an existing secure cookie of the same name — but only if the secure cookie is already in the store. Before sign-in there is no session cookie yet, so there is nothing to protect and the attacker writes freely. __Host- is what closes it, because it works on the name rather than on the contents: step 21 discards any __Host- cookie that is not Secure, host-only and Path=/, and step 13 discards any Secure cookie that arrives over a non-secure connection. Between them, a plain-HTTP response cannot produce a cookie by that name under any combination of attributes, and neither can a sibling host. The specification's own summary: the prefix "yields a cookie that hews as closely as a cookie can to treating the origin as a security boundary."

Set the cookie control to __Host-SID and the rotation control to never. F1, F2 and F3 close anyway — and so does F9, which rotation never touches, because the prefix removes the attacker's ability to choose the string rather than removing one moment at which choosing it pays off. On the plant vectors the cookie name does strictly more than the rotation does. Which is not an argument for skipping the rotation: F4 and F7 belong to it alone, and neither is a plant vector at all.

The setting the panel closes last

Row F9 survives every other control. The path is a remember-me cookie: the browser presents a planted identifier and a valid long-lived token, the application restores the account onto the session it was handed, and the code that rotates never runs because nobody called the sign-in handler. Any silent re-authentication does this — a token refresh, a single-sign-on session that is still live at the identity provider, an "impersonate this user" tool in the admin console.

What closes it is session.use_strict_mode=1, and the reason is upstream of every code path. With it off, PHP's session module accepts an identifier it has never issued and creates a record for it. With it on, the manual's wording is "the module does not accept uninitialized session IDs" — an unknown identifier is discarded and a fresh one is generated. The attacker can no longer choose the string. Everything downstream, including every handler you forgot to audit, inherits that.

The default is 0. This is the single highest-value line in a PHP session configuration and it costs nothing, and it is off, and it has been off since it was added. While you are in that file: session.cookie_secure defaults to 0, session.cookie_httponly defaults to 0, and session.cookie_samesite defaults to the empty string, meaning no attribute is sent at all — which lands you in the per-browser default that only Chrome applies. The two that are safe are session.use_only_cookies, which defaults to 1, and session.use_trans_sid, which defaults to 0, so identifiers in URLs are off unless you turned them on. If you have turned them on, every referrer header, proxy log and shared link is a fixation vector and no rotation policy will help.

OWASP states the general rule this is an instance of: an application should "only accept session ID values that have been previously generated by the web application", and should treat an unknown identifier as suspicious activity rather than as a session to create. Read that as a claim about your session store, not about your login code, and the reason strict mode is the last door to close becomes obvious — it is the only control here that acts before any of your code runs.

Ending a session you have no record of

Row F8 is the one users actually experience. Something feels wrong, they change their password, and the attacker's session carries on working.

With a file-backed or cookie-backed session store, this is not a bug you can fix in the password handler, because the handler has no way to enumerate the sessions. A PHP session file is named after its identifier; there is no index by user. Express's session middleware has been asked "Destroying all sessions for a given user?" for years, and the answer is always the same: not without a store you can query.

Two mechanisms solve it, and they are genuinely different.

A registry. Persist a row per session — identifier, account, created, last seen, user agent, address — and delete or mark them on a password change. This is what "log out everywhere" and the "your active sessions" screen are actually made of. It also closes row F4 for free, because a registry that tracks the current identifier per session drops the old one when it rotates. The cost is a write on the authentication path and a store you now have to operate.

A password-bound session. Put a keyed hash of the stored password inside the session and check it on every request. Django does exactly this: login() writes request.session[HASH_SESSION_KEY] = user.get_session_auth_hash(), where that method returns a salted HMAC over the password field, and get_user() compares the stored value on each request and calls request.session.flush() when it stops matching. Change the password, the hash changes, every session carrying the old one is flushed on its next request — with no registry, no enumeration, and no extra store. Select that option in the panel and F8 closes.

The difference shows in what each one cannot do. The password-bound hash ends every session including the one the user is sitting in, which is why Django ships update_session_auth_hash() to re-stamp the current session after a self-service password change. It also cannot revoke one session, or revoke on anything other than a credential change. The registry can do both and knows nothing about passwords. Row F4 is where they part company in the panel: the registry closes it, the hash does not, because the old record carries a hash that is still perfectly valid.

Row F7 is the third mechanism, and it is the one OWASP words most strongly: "The session ID must be renewed or regenerated by the web application after any privilege level change within the associated user session." Not after authentication. After any privilege level change. A second factor accepted, a sudo-mode timer started, a support agent switching into a customer account, a feature flag that grants an admin scope — each is a moment where the set of people entitled to hold that identifier just changed, and the identifier did not.

Checking your own application

  • Get the identifier before and after. curl -c jar.txt -s https://app.test/login > /dev/null, read the cookie, post the credentials with curl -b jar.txt -c jar.txt, read it again. If the value is the same string, there is no rotation at all. If it changed, try the old value on an authenticated endpoint — a 200 means row F4 is live in your application right now.
  • Read the ini values from the running process, not the file. php -r 'foreach (["use_strict_mode","use_only_cookies","use_trans_sid","cookie_secure","cookie_httponly","cookie_samesite"] as $k) printf("%s=%s\n", $k, ini_get("session.$k"));' inside the container. A .ini in the repository is not evidence; overrides land in a dozen places.
  • Grep for the argument. grep -rn "session_regenerate_id" . and look at what is inside the parentheses. Then look at the two lines around each call and check whether the user identity is written before or after it.
  • List the code paths that produce an authenticated session. Sign-in is one. Remember-me, token refresh, single-sign-on callback, admin impersonation, password reset completion, invitation acceptance and account linking are the ones that are usually missing the rotation. Whatever function you call to rotate, count its callers and compare.
  • Enumerate every host on your registrable domain — certificate transparency will do it, crt.sh/?q=%25.app.test — and ask which of them serve content you do not review. Each one can write your session cookie unless the name starts with __Host-.
  • Test the password change from two browsers. Sign in as the same account in two profiles, change the password in one, then reload in the other. If the second one is still signed in, you have neither a registry nor a password-bound session, and "log out everywhere" is a button that lies.
  • Check the step-up. Sign in, capture the identifier, complete whatever elevates you — second factor, sudo mode, role switch — and capture it again. Same string is row F7.
  • Look for the race before you enable deletion. If you are about to switch to session_regenerate_id(true), search your access logs for requests that arrive within a second of a sign-in on the same address. Those are the ones that will be logged out, and on a checkout page they are the ones that will be reported as lost carts.

Your application calls session_regenerate_id(true) at sign-in, sets Secure, HttpOnly and SameSite=Lax on the cookie, and stores sessions in Redis keyed by identifier. A user's account is compromised. They change their password. What happens to the attacker's session?

None of this touches how the password itself is stored — that is which key-derivation function you chose and what its parameters cost an attacker — and the version of this problem with no server-side record at all, where the credential is a signed token you cannot take back, is why revoking a bearer token is harder than issuing one. The two plain-HTTP rows above have a direct answer of their own: the header that stops the browser speaking HTTP to your hostname in the first place.

Why this concept is on the site

Topics are chosen from places engineers visibly get stuck, and the sources are kept with the lesson so the claim is checkable.