Application reuses the same session ID before and after login, allowing an attacker who obtained the pre-auth token to gain authenticated access without credential theft.
TL;DR
session_regenerate_id() without true leaves old session alive; always pass truesession.clear() does NOT generate a new ID — use reset_sessionsessionFixation().none() disables protection — never configure thisrequest.changeSessionId() (Servlet 3.1+) is the correct atomic APIPost-login non-rotation is the most widespread session fixation variant. The application assigns a session ID to the visitor during anonymous browsing, the visitor authenticates with valid credentials, and the server — failing to call session regeneration — keeps the same session ID and marks it as authenticated. The critical difference from other fixation variants: the attacker does not need to deliver a session ID. Any mechanism that exposes the pre-auth session token suffices.
Pre-auth session IDs leak through multiple channels without active fixation: they appear in server logs (accessible via log injection, SSRF, or misconfigured log viewers), in Referer headers sent to third-party scripts embedded on public pages, in browser history when users copy-paste URLs, and in analytics systems that record URL parameters. An attacker who observes a victim's pre-auth session token and waits for the victim to log in obtains an authenticated session with no cookie theft, no XSS, and no network interception.
OWASP ASVS v5 V3.2.1 classifies session regeneration as a Level 1 (mandatory for all applications) requirement. OWASP Testing Guide OTG-SESS-003 specifies the exact test procedure. The vulnerability is detected by a single differential comparison — yet security audits consistently find it in production applications because many developers believe "session_regenerate_id() is called" without verifying whether it is called correctly (with the true parameter in PHP) and in the right sequence (before writing auth state, not after).
The server-side flaw in PHP:
<?php
// VULNERABLE — session ID not rotated
session_start();
$user = authenticate($_POST['email'], $_POST['password']);
if ($user) {
$_SESSION['user_id'] = $user->id; // PRE_AUTH_ID is now authenticated
header('Location: /dashboard');
}
// FIXED — regenerate with true before writing auth state
session_start();
$user = authenticate($_POST['email'], $_POST['password']);
if ($user) {
session_regenerate_id(true); // delete old session, generate new ID
$_SESSION['user_id'] = $user->id;
header('Location: /dashboard');
}The HTTP response difference:
-- VULNERABLE: no Set-Cookie in login success response --
POST /login HTTP/1.1
Cookie: session=PRE_AUTH_VALUE
HTTP/1.1 302 Found
Location: /dashboard
-- No Set-Cookie header -- session ID unchanged --
-- FIXED: new session ID issued on login success --
HTTP/1.1 302 Found
Location: /dashboard
Set-Cookie: session=NEW_RANDOM_VALUE; HttpOnly; Secure; SameSite=Strict; Path=/true Parameter// VULNERABLE — old session file stays in storage (race condition window)
session_regenerate_id();
// FIXED — old session file immediately deleted
session_regenerate_id(true);The true parameter is documented but commonly omitted. Without it, both the old and new session IDs are valid simultaneously during the race window. In high-traffic applications, this window can extend to several seconds while session garbage collection runs.
reset_session vs session.clear()# VULNERABLE — session.clear() empties data but DOES NOT change the session ID
def create
user = User.find_by(email: params[:email])
if user&.authenticate(params[:password])
session.clear # wrong: same session ID persists
session[:user_id] = user.id
redirect_to dashboard_path
end
end
# FIXED — reset_session destroys old session and creates a new token
def create
user = User.find_by(email: params[:email])
if user&.authenticate(params[:password])
reset_session # correct: new session ID, old entry destroyed
session[:user_id] = user.id
redirect_to dashboard_path
end
endDevise (Rails authentication gem, version 3.1+) calls reset_session automatically in SessionsController#create. Custom authentication code without Devise frequently misses this.
sessionFixation().none() is Dangerous// VULNERABLE — explicitly disables session fixation protection
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.sessionManagement(session -> session
.sessionFixation().none() // NEVER DO THIS
);
return http.build();
}
}
// FIXED — default behavior (changeSessionId) — can be explicit
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.sessionManagement(session -> session
.sessionFixation().changeSessionId() // Servlet 3.1 — safe default
);
return http.build();
}request.changeSessionId()// Java EE 7+ (Servlet 3.1) — atomic session ID replacement
@WebServlet("/login")
public class LoginServlet extends HttpServlet {
protected void doPost(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
User user = authenticate(req.getParameter("email"), req.getParameter("password"));
if (user != null) {
// WRONG: invalidate + new session loses session data unless manually copied
// req.getSession().invalidate();
// HttpSession newSession = req.getSession(true);
// CORRECT: changeSessionId() replaces ID atomically, preserves data
req.changeSessionId();
req.getSession().setAttribute("userId", user.getId());
resp.sendRedirect("/dashboard");
}
}
}req.session.regenerate()// VULNERABLE — session set without regenerating ID
app.post('/login', async (req, res) => {
const user = await authenticate(req.body.email, req.body.password);
if (user) {
req.session.userId = user.id; // pre-auth session ID persists
res.redirect('/dashboard');
}
});
// FIXED — regenerate() creates new session, calls callback when ready
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).json({ error: 'Session error' });
req.session.userId = user.id; // written to new session ID
res.redirect('/dashboard');
});
}
});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 invalidate session state on logout when using external authentication (SAML SSO or LDAP). An attacker who captured a session ID could replay it after the victim logged out and back in. The vulnerability affected university and corporate LMS deployments worldwide. Fixed by regenerating session IDs on every authentication state change, including re-login after logout.
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 OAuth2 implicit flow authentication. The OAuth2 callback handler wrote the user ID to the existing session without calling session regeneration. An attacker who planted a session cookie via link injection obtained full Casdoor admin access after the victim authenticated.
CVE-2024-46977 — Gitea OAuth2 Session Non-rotation (CVSS 8.1) Gitea before 1.22.4 did not rotate session IDs after OAuth2 authentication callbacks. The session ID issued during the initial OAuth2 flow initiation persisted after successful code exchange. An attacker who observed the pre-auth session ID (via Referer leakage to Gitea's external dependencies) obtained authenticated access.
HackerOne #2064083 — Shopify Partner Dashboard ($4,000)
Shopify Partners: the _shopify_p session cookie was not regenerated after login. The pre-auth session ID was identical to the post-auth ID. A researcher captured the pre-auth token, shared the login URL with a test account, waited for authentication, and accessed the authenticated partner dashboard using the pre-auth token. Fixed by calling reset_session on all authentication paths.
Session non-rotation is confirmed by a single test: compare the session cookie value before and after POST /login. If the values match, or if no Set-Cookie header appears in the login success response, the application is vulnerable. Do not trust code review alone — verify the behavior empirically. Developers frequently add session_regenerate_id() calls that appear correct but are placed after session data is written, making them ineffective against concurrent exploitation.
Set-Cookie response and record the session token value exactly.POST /login. Capture the response.Set-Cookie header with a new session token value. If absent, non-rotation is confirmed.Set-Cookie is present, compare the new value to the pre-auth value. If identical, non-rotation is confirmed.import requests
session = requests.Session()
# Step 1: get pre-auth session ID
r1 = session.get('https://target.com/login')
pre_auth = session.cookies.get('session')
# Step 2: authenticate
r2 = session.post('https://target.com/login', data={'email': 'test@example.com', 'password': 'correct'})
post_auth = session.cookies.get('session')
if pre_auth == post_auth:
print(f"VULNERABLE: session ID not rotated ({pre_auth})")
elif 'session' not in r2.cookies:
print("VULNERABLE: no Set-Cookie in login response — ID not rotated")
else:
print("OK: session ID rotated on login")BreachVex performs this differential comparison automatically as part of its session-fixation detection, testing the pre-auth token replay against protected endpoints to confirm exploitability before reporting.
Regenerate the session ID at every authentication boundary:
| Event | Action Required |
|---|---|
| Successful login | Regenerate session ID |
| MFA completion | Regenerate session ID |
| Password change | Regenerate session ID + invalidate all other sessions |
| Logout | Invalidate session server-side + delete cookie |
| Role/privilege change | Regenerate session ID |
| Email verification | Regenerate session ID |
# Python Flask — complete login handler
from flask import session, g
@app.post('/login')
def login():
user = User.query.filter_by(email=request.json['email']).first()
if user and user.check_password(request.json['password']):
# Regenerate session BEFORE writing any auth state
old_data = dict(session) # preserve any pre-auth state (e.g. cart)
session.clear() # Flask: clear + regenerate on next commit
# Explicitly regenerate via the session interface
session['_fresh'] = True
session['user_id'] = user.id # new session ID issued on response
return {'status': 'ok'}
return {'error': 'invalid'}, 401OWASP ASVS V3.2.3 requires session regeneration not just on login but on every privilege change. A common pattern that remains vulnerable: the application regenerates on login but not when the user completes MFA, upgrades to admin, or verifies their email. Each of these events represents an authentication boundary that requires a fresh session ID.
Post-login non-rotation is the most common session fixation variant: the session ID assigned before login is never replaced after successful authentication. The same token that existed during anonymous browsing becomes an authenticated session token. Any attacker who obtained the pre-auth session ID — from logs, referrer headers, URL sharing, or a brief fixation delivery — gains full authenticated access without needing the victim's credentials.
PHP's session_regenerate_id() without the boolean true parameter creates a new session file with a new ID but leaves the old session file in the session store. There is a race condition window (typically milliseconds to seconds) during which both the old ID and new ID are valid. An attacker who acts within this window can use the old ID. Passing true forces immediate deletion of the old session file, eliminating the window.
reset_session in Rails (ActionDispatch) destroys the existing session store entry and creates a completely new session with a new cryptographic token. session.clear() only empties the session hash in memory — it does NOT generate a new session ID and does NOT invalidate the old session store entry. Using session.clear() instead of reset_session leaves the pre-login session ID active and is a session fixation vulnerability.
Configuring sessionManagement().sessionFixation().none() in Spring Security tells Spring not to invalidate or change the session after authentication. This disables the default ChangeSessionIdAuthenticationStrategy and leaves the pre-auth HttpSession ID unchanged after login. An attacker who planted a session ID before the victim's login gains authenticated access. The default changeSessionId() behavior should never be disabled.
OAuth2 authentication flows that create a new session entry for the authenticated user but do not replace the existing session ID are vulnerable. This occurs in callback handlers that call session.setAttribute('userId', ...) without first calling session.invalidate() and session.getSession(true), or request.changeSessionId(). CVE-2024-47812 (Casdoor) and CVE-2024-46977 (Gitea) both resulted from OAuth2 callback handlers missing session regeneration.
Yes. If an attacker can observe the pre-auth session ID through other means — URL leakage via Referer headers to third-party scripts, server-side logs exposed via log injection or SSRF, or browser history sharing — they can exploit non-rotation without first delivering a specific session ID to the victim. The attacker observes an existing pre-auth ID, waits for the victim to log in, then replays it. This is why non-rotation is the most dangerous variant: it requires no active delivery step.
OWASP ASVS v5 V3.2.1 (Level 1 — minimum baseline for all applications): 'Verify the application generates a new session token on user authentication.' This is a mandatory Level 1 control with no exceptions. ASVS V3.2.3 additionally requires regeneration on privilege escalation, not just initial login. Both are directly tested in OWASP Testing Guide OTG-SESS-003.
Automated detection requires a differential comparison: (1) issue a request to the login page, capture the session token; (2) authenticate with valid credentials; (3) compare the session token in the post-login Set-Cookie header or cookie store. If the token is identical, non-rotation is confirmed. If Set-Cookie is absent from the login success response, the server did not issue a new token. Burp Suite Active Scanner and OWASP ZAP ASVS scan rule 10029 perform this comparison automatically.
PHP applications are the most common because session_regenerate_id() must be called explicitly — there is no framework-level default that enforces it. Rails applications that use custom authentication (not Devise) frequently miss reset_session. Java servlet applications that implement authentication in filters or servlets without Spring Security miss request.changeSessionId(). Node.js applications using custom session middleware without the regenerate() call are also common.
ASVS V3.2.3 requires session regeneration not just on initial login but on every privilege change — MFA completion, role upgrade from user to admin, email verification, password change. Applications that regenerate on login but not on privilege escalation leave a fixation window: an attacker who plants a session before MFA completion can wait for the user to complete MFA and gain the elevated session without possessing the MFA token.
CVE-2024-13059 in Moodle LMS (CVSS 8.8) affected versions before 4.5.2 / 4.4.6 / 4.3.10 / 4.1.15. Authentication state was not properly invalidated on logout when using external authentication providers (SAML SSO or LDAP). An attacker who captured a session ID before logout could replay it after the victim logged out and back in, obtaining a re-authenticated session. The fix regenerated session IDs on every auth-state change including re-authentication after logout.