Security Engineering · Authentication
Session Fixation and Why Identifiers Must Rotate
How an attacker who chooses a session identifier inherits the victim's authenticated session, and which state transitions require a fresh identifier to break the link.
Session fixation inverts the usual direction of an attack. Instead of stealing a session identifier after authentication, the attacker supplies an identifier beforehand and waits for the victim to attach credentials to it. The defect is not in the identifier’s randomness, which may be perfect. It is in the server accepting an identifier from an unauthenticated client and then continuing to use the same identifier once that client has authenticated.
The mechanism, in full
Most session implementations have a moment where a client presents an identifier and the server either finds an existing session record or creates a new one. If the presented value is used to create a record, an attacker can obtain a value from the server and hand it to the victim.
The delivery has several well-worn routes. A query parameter that the server honours, ?sessionid=ABC, is the direct form; the attacker sends the victim a link containing a value the attacker chose, and the victim’s browser adopts it. A cookie set by a subdomain the attacker controls is a second route, because a cookie with Domain=example.com set from a compromised or attacker-owned subdomain is sent to the parent domain. A third is any path where the server accepts a client-generated value in a header or form field. The details vary; the precondition is identical, which is that the server treats a client-supplied identifier as authoritative.
After the victim loads the planted value, they log in. If the server keeps the same identifier and merely flips the record’s state from anonymous to authenticated, the attacker, who already knows the identifier, now holds an authenticated session without ever seeing the password or the second factor. The attacker does not need to be present at login, does not need to be on the same network, and does not need to defeat any cryptographic control. The session record simply changed meaning while its key stayed the same.
The reverse timing works too. An attacker who obtains any pre-authentication identifier can wait for a victim to authenticate against it, or can plant the value and let the victim authenticate at leisure. Both variants reduce to the same property: identifier continuity across an authentication event.
Why rotation is the fix rather than a patch
The corrective is to treat authentication as a transition that invalidates the identifier. On a successful login the server generates a new identifier from a cryptographically secure random source, creates a fresh record carrying the authenticated identity, and destroys or invalidates the old record. The old identifier, which the attacker knows, now refers to nothing.
Two details decide whether the rotation is real. The first is that the old record must be invalidated server-side, not merely replaced in the client’s cookie. A server that issues a new cookie but leaves the old session record alive has moved the attacker’s identifier from the browser to the server’s table, where it still works if presented directly. The second is that the new identifier must come from the same generator as any other, with the same entropy. Deriving it from the old one, from the user identifier, or from a counter reintroduces predictability and gives back the property that rotation was meant to establish.
The size of the identifier matters for the same reason it matters everywhere: a session identifier is a bearer token, and its strength is its entropy. The usual guidance is at least 128 bits from a CSPRNG, which encodes to 22 base64url characters or 32 hexadecimal characters. Below that, guessing becomes a practical attack in its own right, and no amount of rotation helps because the attacker can simply guess the post-rotation value.
Transitions that need the same treatment
Login is the obvious transition and not the only one. Any event that changes what the session is authorised to do should produce a new identifier, because each such event is a point where an attacker who pre-holds or has captured an identifier gains the new authority.
Privilege escalation within an application is the clearest case. A session that begins as an ordinary user and then enters an administrative area should not carry its original identifier into that area. If it does, an attacker holding the pre-escalation identifier obtains administrative access without ever authenticating as an administrator. The same reasoning applies to entering a context that unlocks additional data, such as an account-switching or impersonation feature.
Re-authentication is a second case. A step-up prompt that asks for the password again before a sensitive operation, or a fresh second-factor challenge, is only meaningful if the resulting authority is bound to a credential the attacker does not hold. Reusing the identifier leaves the attacker’s copy valid alongside the newly verified one. The relation between re-authentication and the factors themselves is covered in MFA factor independence.
Password change is a third. After a password change, every other session for that account should be invalidated, and the current session should receive a new identifier. This is the standard remedy for the case where the password change is itself a response to a compromise: the attacker’s existing session, established with the old password, must stop working. A system that changes the stored digest but leaves sessions untouched has not evicted the attacker.
Logout is the fourth, and the one most often implemented incorrectly. Logout must delete the server-side record, not just expire the client’s cookie. A cookie is a pointer; if the pointed-to record survives, an attacker who captured the identifier earlier can keep using it after the user believes they have logged out. Expiring the cookie and deleting the record are separate operations and both are required.
The fifth is any change to the authentication context that the session’s record encodes: a change in the enrolled second factor, a change in the bound device, or a change in the tenant or organisation the session is scoped to.
Implementing the swap without losing state
Rotation is easy in a system where the session record holds nothing but an identity. It is harder where the record accumulates state: a shopping cart, a partially completed form, a multi-step flow, a CSRF token, flash messages.
The straightforward approach is to copy the application state from the old record into the new one as part of the swap. That preserves continuity but creates a window in which two records hold the same state, and it means the state is not itself invalidated by the transition. For a login flow that is usually what is wanted. For a privilege transition it may not be, since some state should be dropped when the authority changes.
A cleaner discipline separates two things that are often stored in one place: the credential and the state. The credential is the identifier and the identity binding, and it is replaced wholesale at each transition. The state lives under its own key, or in a store addressed by the identity rather than by the session, and survives the swap because it is not part of what is being rotated. Systems that take this route can rotate the credential without touching the cart, and can invalidate the credential without discarding the user’s work.
The concurrency problem is real. If two tabs hold the same pre-login identifier and the user logs in from one, the second tab’s next request presents an identifier that no longer exists. Whether that produces a logout, a redirect, or a silent new anonymous session is a product decision, but it has to be made, and the record’s replacement should be atomic so that a concurrent request does not observe a half-created state.
def login(request, user, password):
if not verify_password(user, password):
return reject()
old = request.session_id # whatever the client presented
new = secrets.token_urlsafe(32) # 256 bits of entropy
store.create(new, identity=user.id) # fresh record, fresh identity binding
store.delete(old) # old identifier must stop working
request.set_cookie("__Host-session", new, secure=True, httponly=True,
samesite="Lax", path="/")
return redirect("/")
The __Host- prefix in that example is not decoration: it tells the browser to reject any attempt to set the same name without Secure, without Path=/, or with a Domain attribute, which closes the subdomain-planting route described earlier. The attribute model behind that prefix is covered in cookie scope and attributes.
Fixation without cookies
The same defect appears in systems that do not use cookies, and the fix has the same shape.
A token issued in a URL and carried forward in links is fixable by an attacker who can get a link in front of the victim, and it is worse because the token also lands in browser history, referrer headers, and server logs. The remedy is to not accept a session token from the URL at all for authenticated sessions, and where a link must carry a credential, to make it single-use and bound to a short expiry.
Single sign-on protocols have their own version of the problem. A flow that accepts a session identifier from the relying party, or that resumes a session from a parameter rather than from its own store, inherits the same weakness. The binding that matters is between the authentication response and the session the browser will subsequently use, and it must be created by the server after validating the response, not carried in from outside.
Device-bound sessions change the calculus rather than removing it. If a session is bound to a key held by the client, an attacker who plants an identifier still cannot use it without the key, so fixation fails at the point of use. That is the model WebAuthn-based sessions move toward, and it is covered in the WebAuthn ceremony.
Detection and the limits of rotation
Rotation prevents fixation but does not prevent a stolen identifier from being used. If an attacker obtains the post-login value, the session is theirs until it expires or is revoked. Detection therefore has to look at behaviour rather than at the identifier’s provenance.
The practical signals are changes in the client’s network origin, changes in the user agent string, and concurrent use of the same identifier from two places at once. All three are noisy: mobile clients change address constantly, and a user with a laptop and a phone legitimately holds two sessions. A system that terminates a session on any change will produce false positives that cost more than the attack it prevents. Binding the session to a coarse, stable attribute and re-challenging on a change is the usual compromise, and it should be paired with the ability to list and revoke active sessions per account, which gives the user a manual control that does not depend on heuristics.
The residual case is the one rotation cannot address: an attacker who obtains the identifier after authentication and before any transition. Against that, the controls are the ones that bound the credential’s exposure in transit and in storage, and the ones that bound its lifetime. Rotation is a narrow, well-defined fix for a narrow, well-defined defect, and its value comes from being applied at every transition rather than only at the one that is easy to remember.
References
- OWASP Session Management Cheat Sheet — OWASP Foundation
- OWASP Authentication Cheat Sheet — OWASP Foundation
- RFC 6265: HTTP State Management Mechanism — IETF