Session fixation: how it works, how to test it and how to fix it
Session fixation is an attack where the attacker sets a victim's session ID before login, then uses the same ID after the victim signs in. It works when the app keeps the pre-login ID after authentication. The fix is to issue a new session ID at login and at every privilege change, accept only IDs the server created, and send the ID in a cookie only.
Session fixation is a flaw where an application keeps the same session ID before and after login. The attacker gets a valid ID, puts it in the victim's browser, waits for the victim to sign in, and then uses that ID to act as the victim. CWE-384 names it. OWASP describes the attacker as setting the session ID on the victim's browser instead of stealing one.
Test only systems you own or have written authorization to test. Examples use a fictional app on example.com.
The attack in four steps#
- The attacker opens the site and receives a session ID, for example
abc123. The ID is not tied to a user yet. - The attacker makes the victim's browser use
abc123. Ways to do this are in the next section. - The victim signs in. The app marks session
abc123as authenticated for the victim and does not change the ID. - The attacker sends requests with
abc123and is signed in as the victim.
Demonstration only: Harbor Notes, a fictional app
This is a demonstration against a fictional app. No real customer system was tested. The app runs at https://notes.example.com.
Before login, the server sets a cookie:
GET / HTTP/1.1
Host: notes.example.com
HTTP/1.1 200 OK
Set-Cookie: sid=abc123; Path=/; HttpOnly
The victim signs in with sid=abc123. A vulnerable server answers without a new cookie:
POST /login HTTP/1.1
Host: notes.example.com
Cookie: sid=abc123
HTTP/1.1 302 Found
Location: /notes
A fixed server answers with Set-Cookie: sid=9f2c...; Path=/; HttpOnly and drops abc123. In the vulnerable case, the old value now opens the victim's notes.
How attackers plant the ID#
The attack needs a way to place a chosen ID in the victim's browser. Which route works depends on how the app accepts IDs.
| Method | Condition | Notes |
|---|---|---|
| ID in the URL or a form field | The app accepts a session ID from a query string or POST body | The PHP manual says that with session.use_only_cookies off, PHP uses IDs from GET or POST when the cookie is not set. Link the victim to a URL that carries the ID. |
| Cookie set by a sibling subdomain | The session cookie has a broad Domain, or another subdomain can set cookies for the parent domain |
Django's documentation describes this: subdomains can set cookies for the whole domain, so fixation is possible if cookies are allowed from subdomains that trusted users do not control. |
| Script injection | An XSS flaw on the site or a sibling | The script can write document.cookie for a cookie that is not HttpOnly. |
| Network position | Plain HTTP somewhere on the domain | An attacker on the path can inject a Set-Cookie. The __Host- prefix helps here (see below). |
| Server accepts any ID | The server creates a session for an ID it has never issued | PHP is permissive by default, as the OWASP cheat sheet notes. |
Fix: a new session ID at login#
The OWASP Session Management Cheat Sheet states that the session ID must be renewed after any privilege level change, and the previous ID must be destroyed. Login is the most common case. Password change, role change and step-up authentication also count. It also says apps should never accept a session ID they have never generated, and should refuse IDs sent in URL parameters.
PHP. Regenerate after the password check and turn on strict mode in php.ini. The PHP manual calls session.use_strict_mode mandatory for secure sessions and notes that it is off by default.
if (password_verify($password, $hash)) {
session_regenerate_id(true); // true deletes the old session file
$_SESSION['user_id'] = $userId;
}
session.use_strict_mode = On
session.use_only_cookies = On
session.use_trans_sid = Off
session.cookie_httponly = On
session.cookie_secure = On
session.cookie_samesite = "Lax"
Node.js (Express with express-session). req.session.regenerate(callback) creates a new ID and a new session object. The module's own login example uses it before storing the user, as a guard against session fixation.
app.post("/login", (req, res, next) => {
const user = authenticate(req.body);
if (!user) return res.sendStatus(401);
req.session.regenerate((err) => {
if (err) return next(err);
req.session.userId = user.id;
req.session.save((err2) => (err2 ? next(err2) : res.redirect("/notes")));
});
});
Python (Django). django.contrib.auth.login() calls cycle_key() to mitigate session fixation, according to the Django docs. Use login(). If you write your own login, call request.session.cycle_key() yourself before you store the user.
from django.contrib.auth import authenticate, login
user = authenticate(request, username=u, password=p)
if user is not None:
login(request, user) # cycles the session key
Java (Spring Security). Protection is on by default. The reference says Spring Security creates a new session or changes the session ID at login, and the default is changeSessionId on Servlet 3.1 and newer. Setting it to none is not recommended. Write out the strategy if you want it visible in code review:
http.sessionManagement(s -> s
.sessionFixation(f -> f.changeSessionId()));
Other stacks. The rule is the same in Go, C#, Ruby and others: after authentication, delete the old server-side session record, create a new random ID, copy over only the data you need, and set a new cookie. Check your session library for a "regenerate" or "cycle" call, and read its documentation for what it keeps.
Framework defaults help only on the standard path. These custom paths break them:
| Path | Why it breaks |
|---|---|
Custom login code that sets userId in the existing session |
It skips the framework's login helper. |
| SSO, SAML or OAuth callback handlers | The callback may attach the identity to the pre-login session. |
| Step-up authentication or role switch | The ID is renewed at first login but not at the later privilege change. |
| "Remember me" or impersonation features | They create a second path to authenticated state. |
| Logout that only clears a client cookie | The server session stays valid, so the old ID still works. |
Cookie settings that reduce the risk#
The OWASP cheat sheet lists the settings. They shrink the ways an ID can be planted or read, but none of them replaces renewal at login.
Secureis mandatory so the ID is not exposed on plain HTTP.HttpOnlyblocks script access, which closes the XSS route to reading the ID.SameSite=Strict(preferred) orLax, set explicitly.- Omit
Domainand keepPathas narrow as possible, so sibling subdomains do not share the cookie. - Use the
__Host-prefix for session IDs. The cheat sheet says the browser then requiresSecure, noDomainandPath=/, and that this prevents subdomain forgery and HTTPS downgrade attacks. Example from the sheet:Set-Cookie: __Host-SessionID=<value>; Secure; HttpOnly; SameSite=Strict; Path=/. - Enforce expiry and invalidation on the server. Removing a cookie in the browser does not cancel a copy someone else holds.
SSO, OAuth and JWT cases#
Single sign-on adds a second place to lose the rule. The login finishes in a callback, and the callback code must create a new session before it records the identity. Three Jenkins CVEs show the pattern in login and plugin code (see the next section).
A signed JWT sent as a bearer token has no pre-login session value that the attacker can fix. That removes the classic attack, not the risk: stolen tokens, long lifetimes and weak validation remain. The JWT vulnerabilities guide covers those. If a JWT sits inside a server session or a cookie, test the cookie the same way as any other session ID.
Real CVEs#
All entries are from the National Vulnerability Database, checked 9 November 2026.
| CVE | Published | Product | What NVD says |
|---|---|---|---|
| CVE-2014-2066 | 17 Oct 2014 | Jenkins before 1.551 and LTS before 1.532.2 | Remote attackers could hijack web sessions through the "override" of Jenkins cookies. |
| CVE-2018-1000409 | 9 Jan 2019 | Jenkins 2.145 and earlier, LTS 2.138.1 and earlier | Jenkins did not invalidate the existing session and create a new one when a user signed up for a new account. |
| CVE-2018-1000602 | 26 Jun 2018 | Jenkins SAML Plugin 1.0.6 and earlier | An attacker who controls the pre-authentication session can impersonate another user. |
The second and third entries show where the fix gets missed: a sign-up flow and an SSO plugin.
How penetration testers detect and prove it#
OWASP WSTG-SESS-03 describes the test. The evidence standard is strict: a finding needs the same session ID value before and after authentication, and a replay of the pre-login ID that returns an authenticated page.
- Request the login page without cookies. Save the response and the
Set-Cookieheader with a timestamp. - Sign in with a test-owned account, sending the pre-login cookie. Save the response.
- Compare the cookie value before and after. A different value after login means the main fix is present. An unchanged value is the lead.
- Replay the old value from a fresh client with no other cookies, to an endpoint that needs authentication. A
200with the account's own data is the proof. A bare200on a public page proves nothing. - Run controls: replay a random invented ID (the server should reject it or issue its own), replay the ID after logout (it should fail), and repeat after each privilege change (password change, role change, MFA step-up).
- Check other ways to plant an ID: put the ID in a query parameter and a form field, and see whether the server accepts it. Check the cookie's
Domain,Secure,HttpOnly,SameSiteand prefix. - Browser-observe the SSO and callback flows with Chromium developer tools, because the ID may change at one step and not at the next.
We record the request and response pair for each step with timestamps and the framework version, since defaults change between versions. We report the controls that held, for example "ID renewed at password login" and "random IDs rejected", next to any failure, so a fix does not break a working protection.
Session fixation, hijacking and CSRF#
| Session fixation | Session hijacking | CSRF | |
|---|---|---|---|
| Attacker action | Sets the ID, waits for login | Steals an ID in use | Makes the victim's browser send a request |
| Needs the victim to sign in later | Yes | No | The victim must be signed in |
| Main fix | New ID at login and privilege change | HttpOnly, Secure, short lifetimes, TLS | SameSite, anti-forgery tokens |
See CSRF testing for the third column.
Testing checklist#
- Compare the session cookie before and after login, sign-up, SSO callback, password change and role change.
- Replay the pre-login ID and record whether an authenticated page returns.
- Send an ID the server never issued and check that it is rejected.
- Try the ID in a URL parameter and a form field.
- Check that logout destroys the server-side session and the old ID fails.
- Check
Secure,HttpOnly,SameSite,Domainand the__Host-prefix. - Check whether subdomains can set cookies for the parent domain.
- Check session lifetime and idle timeout on the server.
- Record the framework and library versions tested.
When to get a professional test#
Framework defaults hide this bug until a team adds custom login, an SSO callback or a new privilege level. A professional test walks every path that creates authenticated state, compares IDs at each one and keeps the evidence. See web application penetration testing for scope and deliverables, and the authentication testing checklist for the wider session checks. Testing starts only after written authorization from the system owner.
Questions
What is the difference between session fixation and session hijacking?
In hijacking the attacker steals a session ID that already belongs to a signed-in user. In fixation the attacker chooses the ID first and waits for the victim to sign in with it. Fixation is prevented by renewing the ID at login. Hijacking needs other controls, such as HttpOnly cookies and TLS.
How do I test whether my app is vulnerable to session fixation?
Open the site without signing in and note the session cookie. Sign in. If the cookie value did not change, the app is vulnerable. Then send the old value in a new request. If it returns an authenticated page, you have proof. Use a test account on a system you are authorized to test.
Does session_regenerate_id fix session fixation in PHP?
Calling it after a successful login is the standard fix, and OWASP names it. Also enable session.use_strict_mode so PHP rejects IDs it did not create, and keep session.use_only_cookies on so IDs in the URL are ignored.
Are JWTs affected by session fixation?
A signed JWT that the server issues at login has no pre-login value to fix, so the classic attack does not apply. Other risks do, such as token theft and weak validation. See the JWT vulnerabilities guide on this site.
Do I still need to renew the session ID if I use HTTPS?
Yes. OWASP states that regenerating the ID is needed whether the site uses HTTP or HTTPS. TLS protects the ID in transit. It does not stop an attacker from giving the victim an ID the attacker already holds.
Sources
- OWASP Session Management Cheat Sheet (checked 9 November 2026)
- OWASP: Session fixation (checked 9 November 2026)
- OWASP WSTG: Testing for Session Fixation (WSTG-SESS-03) (checked 9 November 2026)
- CWE-384: Session Fixation (checked 9 November 2026)
- PHP manual: Session security configuration (checked 9 November 2026)
- express-session documentation (checked 9 November 2026)
- Django documentation: How to use sessions (checked 9 November 2026)
- Spring Security reference: Session management (checked 9 November 2026)
- NVD: CVE-2014-2066 (Jenkins) (checked 9 November 2026)
- NVD: CVE-2018-1000409 (Jenkins) (checked 9 November 2026)
- NVD: CVE-2018-1000602 (Jenkins SAML Plugin) (checked 9 November 2026)