Session fixation attack: a local curl experiment

Session fixation is a flaw where an application keeps the same session ID before and after login, so an attacker who planted that ID gets the signed-in session. We reproduced it against a small local login service. When the server kept the pre-login session ID after authentication, an ID that the attacker had obtained returned the victim's account page with status 200. When the server issued a new ID at login and deleted the old one, the same request returned 401.

This is a scripted Python and curl experiment with downloadable source and responses. No AI agent or model ran the test.

What a session fixation attack is

Web applications track a signed-in user with a session ID, usually in a cookie. A session fixation attack works when the application keeps the same ID before and after login. The attacker obtains a valid ID, gets the victim to sign in while the browser uses that ID, and then presents the same ID to the server.

OWASP describes the flaw as an application that, when authenticating a user, does not assign a new session ID. CWE-384 lists it as authenticating a user without invalidating any existing session identifier. The OWASP Session Management Cheat Sheet says the ID must be renewed after any privilege level change and names the move from anonymous to authenticated as the most common case.

Session fixation versus session hijacking

Session fixation sets or learns the session ID before the victim logs in, so the attacker already holds it when the session becomes authenticated. Session hijacking steals the ID of a session that is already established, for example from a leaked log or an intercepted cookie. OWASP states that session fixation is not a class of session hijacking.

The defences differ. Protecting an established ID from theft does not help against fixation, because the attacker never needs to steal it. Renewing the ID at login removes the value the attacker planted, and this fixture tests that control alone.

Scope of the fixture

  • Target: two short-lived HTTP servers bound to IPv4 loopback, 127.0.0.1, on ports the operating system chooses.
  • Endpoints: GET /session issues an anonymous session cookie, POST /login authenticates, GET /account returns the signed-in user's page.
  • Roles: the attacker and the victim are two curl command lines in one script. The victim's curl sends the attacker's ID as a cookie. This replaces the real-world step of planting the ID in a browser.
  • Data: one synthetic user, victor, with a made-up password. Session IDs are 32 random hex characters generated per run.

The vulnerable mode is intentionally insecure and has no setting for a different bind address. Test only systems you own or have written permission to test; the Acceptable Use Policy describes authorized use here. This article gives no permission to test another system.

Run the downloadable fixture

Download fixture.py and verify.py into one directory. You need Python 3.9 or later and curl. Read the source, then run:

python3 verify.py --output rerun

The output directory must be new. The runner starts the vulnerable server, sends six curl requests, stops it, and repeats the same six requests against the fixed server. Each request bypasses proxies, has a three-second limit and follows no redirects. Our recorded command was python3 verify.py --output recorded-run. It exited 0 and printed {"checks": 12, "passed": true, "failures": []}.

The run on 10 November 2026 used fixture version 1.0.0, Python 3.15.2 and curl 8.7.1 on macOS. The vulnerable server listened on port 52659 and the fixed server on port 52666. A rerun produces different ports, session IDs and response dates. results.json holds the exact versions, timestamps, commands and source SHA-256 hashes.

Observed results

Step 1 asks the server for an anonymous session and stores the cookie as the attacker's ID. Step 2 shows that the ID alone grants nothing. Step 3 sends a wrong password with the same ID. Step 4 is the victim's real login with the attacker's ID set. Step 5 is the victim reading the account page. Step 6 is the attacker using the original ID again.

StepRequestVulnerableFixed
1Attacker: GET /session200, cookie issued200, cookie issued
2Attacker ID: GET /account before login401401
3Victim, wrong password, attacker ID: POST /login401, no new cookie401, no new cookie
4Victim, correct password, attacker ID: POST /login200, no Set-Cookie200, new Set-Cookie
5Victim's current ID: GET /account200200
6Attacker's original ID: GET /account200, victim's page401

Step 6 is the finding. In vulnerable mode the response to step 6 returns {"note": "Victor synthetic account page", "user": "victor"} to a client that never sent a password. In fixed mode the same request returns {"error": "authentication_required"}. Steps 2 and 3 are negative controls and step 5 is the positive control: the victim keeps working access in both modes.

Verify a session fixation finding before reporting it

A 200 on /account is weak evidence by itself, because some applications return a generic page for any session. Record these facts together:

  • The ID was issued to the attacker's client before authentication, and an unauthenticated request with it was denied (step 2).
  • The login response carried no new session cookie, or the ID presented after login equals the ID from before login.
  • The same ID, sent by the attacker's client, returned data that belongs to the victim (step 6).
  • The victim's own session returned the same data (step 5), which ties the page to the victim's account.

A report should also state how the ID reaches a victim in the real application, because the fixture does not test that. Common routes depend on the application, for example an ID accepted from a URL, or a cookie that another part of the site can set. Without a delivery route, a fixation finding is a missing control, and its severity depends on how hard delivery is. This experiment does not rate severity. A report must also say whether the ID was planted before login (fixation) or stolen after it (session hijacking), because the fixes differ. Missing checks on who may read an account page are a separate topic, covered in broken access control.

Fix: issue a new session ID at login and retest

The vulnerable branch of the login handler marks the existing session as authenticated:

sessions[old]["user"] = user               # vulnerable: keep the old ID

The fixed branch deletes the pre-login session, creates a new random ID and returns it in Set-Cookie:

if self.server.mode == "fixed":
    del sessions[old]                      # invalidate the pre-login ID
    new = secrets.token_hex(16)            # issue a new ID at login
    sessions[new] = {"user": user}
    return self.reply(200, {"login": "ok", "user": user}, cookie=new)

The fix needs both steps. Issuing a new ID without deleting the old session would leave the attacker's ID alive, a case this fixture does not test. In the recorded run the victim's login returned a new ID, 499daed97ba66c5e8e864a6811554d9c, and the attacker's original 9c49b4cac21557c0ebde63be301f8776 was refused with 401. Where your framework has a session regeneration call, use it instead of a hand-written handler like this one.

The retest repeats all six requests in fixed mode. Wrong passwords still return 401, and the victim still reads the account page with the new ID. Both modes set the cookie with HttpOnly and SameSite=Lax, and the flaw reproduced anyway. Those attributes, covered in CSRF and SameSite, address other attacks and do not prevent fixation in this fixture.

Inspect the evidence bundle

The bundle includes the raw HTTP response text with dates and server headers. All credentials and session IDs are synthetic. No production data was involved.

What this experiment does not cover

Twelve requests against one toy service show one control. We did not test how an ID gets into a victim's browser, HTTPS and the Secure cookie flag, session timeouts, logout, concurrent sessions, session IDs carried in URLs, token-based sessions or a real framework. The fixture also omits the Secure attribute because it runs over plain HTTP on loopback. A passing retest here does not show that another application renews its IDs at every privilege change, such as a password change or a role switch.

For a real engagement, capture the session cookie before login and after login on a staging system you are authorized to test, compare them, and replay the old value from a second client. Keep both successful and denied results. The same check applies after a sign-in with a second factor, as covered in 2FA bypass testing. Then retest after every change to authentication code.

Excession Labs runs this kind of session and authentication testing in its security assessments, and checks that the session ID changes at each login and privilege change. See how a finding like this looks in a sample report.