Session fixation (CWE-384) lets an attacker pre-set a victim's session ID before login, then hijack the authenticated session without intercepting cookies.
TL;DR
reset_session, session_regenerate_id(true), req.session.regenerate(), request.changeSessionId())HttpOnly, Secure, SameSite=Strict, and __Host- prefix eliminate most delivery vectorsSession fixation (CWE-384) is a session management vulnerability where an attacker establishes a session identifier that a victim will unknowingly use to authenticate. The attacker does not steal a session — they pre-plant one. When the application accepts a user-supplied session ID and fails to replace it after successful authentication, the attacker's pre-chosen token becomes an authenticated session.
The attack was formally documented by Mitja Kolsek in 2002 in "Session Fixation Vulnerability in Web-based Applications" and classified under OWASP Top 10 2021 A07 (Identification and Authentication Failures). It remains systematically undercounted in vulnerability statistics because it requires differential testing — comparing the session token before and after the authentication boundary — rather than a simple scan for known signatures. OWASP ASVS v5 V3.2.1 mandates session regeneration at Level 1 (lowest baseline), meaning this control should be present in every web application, yet security audits consistently find it missing in legacy PHP, Ruby on Rails, and Java servlet applications.
Session fixation differs from session hijacking in directionality. Hijacking steals a session that already exists through XSS, network interception, or log exposure. Fixation pre-plants the session value, making it viable against HTTPS-only targets where traffic interception is impractical. An attacker on a fully TLS-encrypted target can still execute session fixation if the application accepts session IDs through URL parameters or if cookie delivery can be achieved through subdomain compromise.
The complete attack flow involves three phases: planting the session ID, delivering it to the victim, and harvesting the post-authentication session.
The attack succeeds at step 4: the server authenticates the user but reuses the attacker-chosen session ID instead of issuing a fresh one. From this point, the attacker's requests using the known token are treated as authenticated.
The exact HTTP exchange for URL-parameter fixation:
GET /login?JSESSIONID=ABC123ATTACKER HTTP/1.1
Host: bank.example.comAfter the victim authenticates, any request from the attacker using the same token succeeds:
GET /account/dashboard HTTP/1.1
Host: bank.example.com
Cookie: JSESSIONID=ABC123ATTACKER
HTTP/1.1 200 OK
Content-Type: text/html
<!-- victim's account page rendered here -->| Variant | Delivery Mechanism | CWE | Real Example |
|---|---|---|---|
| URL parameter fixation | ?JSESSIONID=, ?PHPSESSID=, ?sid= in link | CWE-384 | CVE-2024-42346 (Portainer) |
| Cookie injection via XSS | document.cookie = "session=KNOWN" | CWE-384 + CWE-79 | HackerOne #1629543 (GitLab) |
| Cross-subdomain fixation | Compromised subdomain sets Domain=.example.com cookie | CWE-384 + CWE-1275 | HackerOne #1234231 (Auth0 tenant) |
| Meta refresh fixation | HTML <meta http-equiv=refresh content=0;url=...?sid=KNOWN> | CWE-384 | OWASP OTG-SESS-003 documented |
| Post-login non-rotation | Session ID not replaced after POST /login succeeds | CWE-384 | CVE-2024-13059 (Moodle CVSS 8.8) |
| OAuth2 callback non-rotation | Session not regenerated after OAuth2 code exchange | CWE-384 | CVE-2024-47812 (Casdoor), CVE-2024-46977 (Gitea) |
URL parameter fixation exploits legacy behavior in Java servlet containers (pre-Servlet 3.1) and PHP applications with session.use_only_cookies = 0. The attacker constructs a URL including the session token and sends it to the victim via email, SMS, or social engineering.
Cookie injection via XSS uses JavaScript to plant a session cookie in the victim's browser. A stored XSS vulnerability on any page of the same origin can execute document.cookie = "session=ATTACKER_VALUE; path=/" before the victim reaches the login page. The subsequent login binds the attacker-chosen token.
Cross-subdomain fixation requires a foothold on any subdomain of the same eTLD+1. If uploads.example.com is taken over (abandoned CNAME, expired certificate, or misconfigured DNS), an attacker can issue Set-Cookie: session=KNOWN; Domain=.example.com; Path=/ responses. The victim's browser stores this cookie and sends it to example.com on all future requests, including the login form.
Post-login non-rotation requires no delivery mechanism at all — just an existing pre-auth session ID. This is the most prevalent variant in production applications: the developer assigns user data to the session without first regenerating the token, leaving the pre-auth session ID active with authenticated privileges.
Post-login non-rotation is confirmed by comparing the Set-Cookie header value before and after POST /login. If the session token is identical — or absent from the response (meaning no new token was issued) — the application is vulnerable. Automated scanners that do not perform differential comparison will miss this entirely.
CVE-2024-12798 — Apache Tomcat Session Fixation (CVSS 8.1) Apache Tomcat 11.0.0-M1 through 11.0.1, 10.1.0-M1 through 10.1.33, and 9.0.0.M1 through 9.0.97 were affected by a partial HTTP/1.1 request handling race condition. Concurrent requests processed on the same connection could result in session fixation when a partial frame triggered premature session allocation. Fixed in Tomcat 11.0.2, 10.1.34, and 9.0.98.
CVE-2024-13059 — Moodle Post-logout Session Reuse (CVSS 8.8) Moodle LMS before 4.5.2 / 4.4.6 / 4.3.10 / 4.1.15 did not properly invalidate session state on logout when using external authentication providers (SAML SSO or LDAP). An attacker who captured a pre-logout session ID could replay it after the victim logged out and back in, obtaining an authenticated session. CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H.
CVE-2024-42346 — Portainer URL Session Fixation (CVSS 8.8)
Portainer CE/EE before 2.21.0 accepted session IDs delivered via URL parameters for certain management endpoints. An attacker who shared a crafted URL portainer.example.com/#!/auth?sessionId=KNOWN could fix the session ID before the victim authenticated, then access Portainer's container management interface with admin privileges.
CVE-2024-47812 — Casdoor OAuth2 Non-rotation (CVSS 8.8) Casdoor open-source IAM before 1.802.0 did not regenerate the session ID after completing an OAuth2 implicit flow authentication. Session fixation via link injection gave attackers authenticated access to any Casdoor-protected application.
HackerOne #2064083 — Shopify Partner Dashboard Session Fixation ($4,000)
Shopify Partners: the _shopify_p session cookie was not regenerated after login. Attacker accessed the dashboard login page, captured the pre-auth cookie, shared the URL with the victim, and obtained full partner dashboard access after the victim authenticated. Fixed by calling reset_session on every successful authentication path.
HackerOne #1629543 — GitLab OAuth2 Session Fixation ($3,000) GitLab did not rotate the session ID after OAuth2 code redemption. An attacker who planted a session cookie via subdomain XSS obtained full account access when the victim authenticated via Google SSO. The finding was escalated from Medium to High severity because admin accounts were reachable.
Session fixation is frequently chained with subdomain takeover. Security teams that remediate XSS but leave expired DNS records pointing to decommissioned infrastructure create ongoing session fixation delivery vectors. Any subdomain that can write cookies for the parent domain is a session fixation enabler — even if the subdomain itself has no application functionality.
Set-Cookie response header — record the session cookie name and value exactly.?PHPSESSID=TEST_VALUE, ?JSESSIONID=TEST_VALUE, and ?session=TEST_VALUE to the login URL. Submit credentials. Capture post-login Set-Cookie. If the attacker-chosen value appears or if the server does not issue a new cookie, URL fixation is confirmed.Set-Cookie header appears in the login response, post-login non-rotation is confirmed.Set-Cookie for HttpOnly, Secure, SameSite, and the presence/absence of Domain with a leading dot. Each missing attribute is an independent finding.subfinder/amass. For each subdomain, test whether it can set cookies with Domain=.parent.com. Combine with subzy/subjack for takeover candidates.Burp Suite Active Scanner compares session tokens before and after authentication. Burp Session Handling Rules can be scripted to automate the differential comparison for all authenticated scan requests.
# OWASP ZAP — ASVS Level 1 scan rule 10029
# Checks for session token non-rotation at login
zap-cli active-scan -r session-fixation --target https://target.comNmap NSE http-session-fixation script performs a basic pre/post comparison:
nmap --script http-session-fixation -p 443 target.comBreachVex detects session fixation via a differential three-step probe: capture pre-auth session token, authenticate with valid credentials using the pre-auth token, compare post-auth session value. A match triggers CWE-384 HIGH with a full evidence trail including the identical token values, request timestamps, and authenticated resource confirmation.
The single mandatory control: generate a new session ID immediately upon successful authentication. Every framework provides this:
<?php
// PHP — VULNERABLE: session data set without regeneration
session_start();
if (authenticate($_POST['email'], $_POST['password'])) {
$_SESSION['user_id'] = $user->id; // attacker's session ID now authenticated
}
// PHP — FIXED: regenerate with true to delete old session file
session_start();
if (authenticate($_POST['email'], $_POST['password'])) {
session_regenerate_id(true); // true = delete old session from storage
$_SESSION['user_id'] = $user->id;
}# Rails — VULNERABLE: session set without reset
def create
user = User.find_by(email: params[:email])
if user&.authenticate(params[:password])
session[:user_id] = user.id # pre-login session ID persists
redirect_to dashboard_path
end
end
# Rails — FIXED: reset_session before writing auth state
def create
user = User.find_by(email: params[:email])
if user&.authenticate(params[:password])
reset_session # destroys old session, creates fresh token
session[:user_id] = user.id
redirect_to dashboard_path
end
end// Node.js Express — VULNERABLE: session set directly
app.post('/login', async (req, res) => {
const user = await authenticate(req.body.email, req.body.password);
if (user) {
req.session.userId = user.id; // pre-login session ID persists
res.redirect('/dashboard');
}
});
// Node.js Express — FIXED: regenerate() before writing auth state
app.post('/login', async (req, res) => {
const user = await authenticate(req.body.email, req.body.password);
if (user) {
req.session.regenerate((err) => {
if (err) return res.status(500).send('Session error');
req.session.userId = user.id;
res.redirect('/dashboard');
});
}
});// Java Servlet 3.1+ — FIXED: changeSessionId() atomically replaces ID
@WebServlet("/login")
public class LoginServlet extends HttpServlet {
protected void doPost(HttpServletRequest req, HttpServletResponse resp) {
User user = authenticate(req.getParameter("email"), req.getParameter("password"));
if (user != null) {
req.changeSessionId(); // Servlet 3.1 — replaces ID, preserves data
req.getSession().setAttribute("userId", user.getId());
resp.sendRedirect("/dashboard");
}
}
}Set-Cookie: __Host-session=<token>; Secure; HttpOnly; SameSite=Strict; Path=/; Max-Age=3600The __Host- prefix forces: Secure flag required, no Domain attribute (host-only), Path=/ required. This eliminates cross-subdomain delivery entirely. Spring Security 5.8+, Rack 3+, and Django 4.2+ support __Host- prefix natively.
# FastAPI / Starlette — __Host- prefix session cookie
response.set_cookie(
key="__Host-session",
value=new_session_token,
httponly=True,
secure=True,
samesite="strict",
max_age=3600,
path="/",
# domain= omitted intentionally — __Host- requires no Domain attribute
); php.ini — block URL-delivered session IDs at the framework level
session.use_only_cookies = 1 ; reject URL-based session IDs
session.use_strict_mode = 1 ; reject externally supplied session IDs
session.cookie_httponly = 1
session.cookie_secure = 1
session.cookie_samesite = "Lax"<!-- Tomcat context.xml — disable URL rewriting (JSESSIONID in path) -->
<Context useHttpOnly="true">
<Manager sessionAttributeValueClassNameFilter=""/>
</Context>
<!-- web.xml: disable URL rewriting via session tracking mode -->
<session-config>
<tracking-mode>COOKIE</tracking-mode>
</session-config>Spring Security's default sessionFixation().changeSessionId() (since 3.2) is correct. Applications that override httpSecurity.sessionManagement().sessionFixation().none() to "improve performance" disable this protection. The performance impact of changeSessionId() is negligible — it only updates the session ID reference in the store, not the session data.
Session fixation (CWE-384) is an authentication attack where the attacker establishes a known session ID before the victim logs in. If the application reuses that ID post-authentication, the attacker gains an authenticated session without ever stealing a cookie. The fix is to call session regeneration — reset_session in Rails, session_regenerate_id(true) in PHP, req.session.regenerate() in Express, request.changeSessionId() in Java — immediately after a successful login.
In session hijacking the attacker steals a session ID that already exists (via XSS, network sniffing, or log exposure). In session fixation the attacker plants the session ID before the victim logs in — no interception required. The attacker already knows the ID because they chose it. This makes fixation viable against HTTPS targets where sniffing is impractical.
URL-based fixation occurs when the application accepts session IDs from URL query parameters. The attacker shares a link containing a pre-chosen session ID — for example https://target.com/login?JSESSIONID=ATTACKER123 — the victim clicks the link and authenticates, and the server reuses the attacker's token. PHP with session.use_only_cookies = 0 and legacy Java servlet containers (pre-Servlet 3.1) are historically vulnerable. CVE-2024-42346 (Portainer) is a recent real-world example.
Cross-subdomain fixation exploits cookies with a leading-dot domain attribute (Domain=.example.com). Any subdomain — including attacker-controlled ones obtained via subdomain takeover — can set cookies for the parent domain. The attacker plants a session cookie from a compromised or misconfigured subdomain, the victim authenticates on the main app, and the non-rotated session gives the attacker access. Using __Host- prefix cookies prevents this entirely.
The attacker crafts an HTML page or email containing a meta refresh: <meta http-equiv=refresh content=0;url=https://target.com/login?sid=ATTACKER_VALUE>. When the victim views the page, their browser navigates to the login URL with the attacker-chosen session token. If the application accepts URL-delivered sessions and does not rotate on login, fixation succeeds. This vector works without JavaScript and bypasses many CSP protections.
Post-login non-rotation is the most prevalent session fixation variant: the session ID issued before authentication (during browsing) is never replaced after a successful login. An attacker visits the target site, captures the pre-auth session ID (from logs, shared URLs, or a controlled network path), delivers the URL to the victim, and after login the unchanged session ID is now authenticated. Rails applications missing reset_session, PHP apps missing session_regenerate_id(true), and Spring apps configured with sessionManagement().sessionFixation().none() are all vulnerable.
PHP's session_regenerate_id() without the boolean true parameter creates a new session file but leaves the old session file on disk — a race condition window where both IDs are valid. Passing true forces immediate deletion of the old session file, closing the window. This is a critical distinction: many PHP tutorials omit the true parameter, leaving a partial fixation window. Always use session_regenerate_id(true) immediately after login.
The __Host- cookie name prefix (RFC 6265bis) forces the browser to accept Set-Cookie only from the exact host (no subdomain), with path=/ and the Secure flag set, and with no Domain attribute. A cookie named __Host-session can only be set by example.com — not by sub.example.com — preventing cross-subdomain fixation delivery. It also prevents protocol downgrade delivery. Spring Security 5.8+ and Rack 3+ support __Host- prefix natively.
Burp's active scanner compares the session token before and after a successful authentication request. If the token is identical pre- and post-login, it flags CWE-384. The Session Handling Rules feature can automate this: define a rule that captures the session token at the start of a test sequence and verifies it changes after the login action. Burp's Sequencer tool also measures session ID entropy to detect predictable tokens.
Key recent CVEs: CVE-2024-12798 (Apache Tomcat partial request session fixation, CVSS 8.1), CVE-2024-13059 (Moodle post-logout session reuse, CVSS 8.8), CVE-2024-47812 (Casdoor OAuth2 non-rotation, CVSS 8.8), CVE-2024-42346 (Portainer URL session fixation, CVSS 8.8), CVE-2024-46977 (Gitea OAuth2 session non-rotation, CVSS 8.1). All share the same root cause: session ID not regenerated at the authentication boundary.
Rails calls reset_session automatically in Devise (since 3.1.0) before setting the user session. In vanilla Rails, you must call reset_session in your SessionsController#create action before writing user_id to the session. This destroys the current session store entry and creates a fresh one with a new random token. Forgetting reset_session (or calling it after setting session data) is the most common Rails session fixation mistake.
request.changeSessionId() was introduced in Servlet 3.1 (Java EE 7) as the standard API to regenerate a session ID without losing session data. It atomically assigns a new session ID to the existing session, invalidates the old ID at the session manager level, and updates the Set-Cookie response header. Prior to Servlet 3.1, the only safe method was invalidate() followed by getSession(true), which required manually copying session attributes — error-prone in practice.
SameSite=Strict prevents the fixation delivery vector (attacker cannot set the cookie cross-site) but does not prevent session fixation via URL parameters or meta-refresh within same-site navigation. The fundamental fix is always regenerating the session ID on authentication — SameSite is defense-in-depth that reduces the delivery surface. SameSite=Lax provides less protection than Strict because it allows GET-triggered navigation from external origins.
OWASP ASVS v5 V3.2.1 (Level 1 — minimum baseline): 'Verify the application generates a new session token on user authentication.' This is a Level 1 control, meaning it is required for all applications regardless of risk profile. V3.1.1 also requires: 'Verify the application never reveals session tokens in URLs, error messages, or logs.' Both are tested in penetration test scope under OWASP Testing Guide OTG-SESS-003.