4Station 4 of 6: Session
The session id chosen before sign-in
When the session id does not change on login, an attacker can plant a session id in someone's browser, wait for them to sign in with it, and then use that same id to access the authenticated session.
$ uv run python -m wire run 6$ uv run python -m wire check 6Goal#
Fix a login flow that reuses the same session id before and after authentication, allowing an attacker to plant a session id and hijack the session once the victim signs in.
The idea#
What session fixation is
Session fixation is the attack where someone else provides the session id, the victim authenticates with it, and the attacker then uses that same id to access the authenticated session.
A typical web shop gives every visitor a session immediately, so the basket works before they sign in. When they sign in, the shop marks that existing session as authenticated. The session id existed before the user proved who they are, so anyone who managed to plant an id in the victim's browser is signed in along with them.
The attacker never sees the victim's password. The victim does everything correctly. The flaw is that the session id was chosen before authentication.
Why session ids must change on login
Before login, a session is unauthenticated. The server knows nothing about the user. The session might contain a basket, a language preference, or a redirect URL, but no identity. Anyone can get an unauthenticated session by visiting the site.
After login, the session is authenticated. It maps to a user identity, and every request with that session id is treated as coming from that user. If the session id does not change, an attacker can:
- Visit the site and get a session id.
- Plant that id in the victim's browser (on a shared computer, by setting the cookie directly in the browser's storage, or on separate machines by serving it via a related subdomain).
- Wait for the victim to add items to the basket and sign in.
- Use the same session id to access the victim's account.
The fix is to issue a new session id on every authentication event: login, privilege escalation, password change. Never reuse a session id across a trust boundary.
How session regeneration works
When the user signs in, the server must:
- Copy any unauthenticated state (basket, redirect URL) from the old session.
- Delete the old session from the session store.
- Create a new session with a fresh id, associated with the user, containing the copied state.
- Set the new session id in the response cookie.
After step 2, the old session id is invalid. The attacker who planted it gets nothing.
def sign_in(self, session_id: str, email: str) -> str:
session = self.data.pop(session_id)
new_id = secrets.token_urlsafe(32)
self.data[new_id] = {**session, "user": email}
return new_id
The basket survives, but the id changes. The victim's browser gets the new id and continues. The attacker's planted id stops working.
What people get wrong
Marking the session as authenticated without changing the id. The simplest implementation is session["user"] = email. This works for normal users, but it hands an authenticated session to anyone who planted the old id. The fix is to always regenerate the id on login.
Forgetting to delete the old session. If you create a new session but do not delete the old one, the old id still works. An attacker with the old id can still access the account. The fix is to delete the old session as part of the login flow.
Losing unauthenticated state. If you create a fresh session and forget to copy the basket or redirect URL, the user sees an empty basket after login. The fix is to copy the necessary state (but only the state you understand; do not blindly copy everything).
How production systems handle session fixation
Framework behavior. Django calls request.session.cycle_key() automatically on login, which regenerates the session id. Rails has reset_session, which clears and regenerates. Flask with Flask-Login requires you to call session.regenerate() explicitly. Express with express-session requires req.session.regenerate(callback). ASP.NET Core regenerates automatically. Check your framework's documentation and verify the behavior in your auth code.
When to regenerate. Regenerate on every privilege boundary:
- Initial login. Unauthenticated to authenticated.
- Privilege escalation. User to admin. If the user re-enters their password to access admin pages, issue a new session.
- Password change. Treat it as a new login. Revoke all other sessions and issue a new one for the current device.
- Account recovery. After a password reset, revoke all sessions and require re-authentication.
CSRF tokens. CSRF tokens are also sessions of a sort. If an attacker can set a CSRF token before the user signs in, they can bypass CSRF protection. Regenerate CSRF tokens on login, just like session ids.
OAuth state parameters. OAuth flows use a state parameter to prevent CSRF. If an attacker can set the state before the user starts the OAuth flow, they can hijack the authorization. Generate the state when the user clicks the login button, not when the page loads, and validate it strictly.
Per-device sessions. Some apps track sessions per device (laptop, phone, tablet). When the user signs in on a new device, create a new session for that device. When they sign out on one device, invalidate only that session. When they change their password, invalidate all sessions. Store a list of active sessions per user so you can revoke them individually or all at once.
The case#
Run the case to see the fixation:
uv run python -m wire run 6
case 06: The session id chosen before sign-in
session id before sign-in: oNYP1T8KZz_H...
session id after sign-in: oNYP1T8KZz_H...
the user sees: user=ada@example.com, basket=['Designing Data-Intensive Applications']
the planted id sees: user=ada@example.com
The session id did not change on sign-in. The attacker planted a session id, the victim signed in with it, and the attacker can now see the victim's identity.
Reasoning towards the fix#
Open lab/cases/case06_fixation.py. The Sessions class stores sessions in a dictionary. Each session holds {"user": email or None, "basket": [...]}. The get_or_start method returns an existing session or creates a new one. The sign_in method is supposed to mark a session as authenticated.
As written, sign_in just updates the session in place: self.data[session_id]["user"] = email. The session id does not change.
The app function handles three routes:
POST /basketadds an item to the basket, even before sign-in.POST /loginchecks credentials and callssessions.sign_in.GET /mereturns the user and basket.
The story function demonstrates the attack:
- The planter visits
/meand gets a session id. - The planter sets that id in the browser's cookies directly.
- The browser adds a book to the basket and signs in.
- The server calls
sign_inon the planted session id, associating it with the user. - The planter uses the same id and sees the user's identity.
To fix it, issue a new session id on sign-in:
def sign_in(self, session_id: str, email: str) -> str:
"""Mark the session as the user's. Returns the session id to use from now on."""
session = self.data.pop(session_id)
new_id = secrets.token_urlsafe(32)
self.data[new_id] = {**session, "user": email}
return new_id
This deletes the old session, creates a new one with a fresh id, copies the basket across, and associates the new id with the user. The old id is now invalid.
The login handler must call sign_in, get the new session id back, and set it in the response cookie. The planted id is deleted from sessions.data, so the planter's next request returns no user.
Check your fix#
Once you have made the changes, check them:
uv run python -m wire check 6
As shipped, the check output is:
FAIL the session id changes at sign in
FAIL the id from before sign in is not signed in (ada@example.com)
PASS the user is signed in
PASS the basket survives sign in
not yet. Try: uv run python -m wire hint 6
When fixed, all lines show PASS. The session id changes on login, the old id is invalidated, and the basket persists across the change.
Try it on your own machine#
Test your own login flows. Start a session before signing in, note the session id, sign in, and check the session id again. If it is the same, you have the problem.
curl -c cookies.txt https://yourapp.example/some-page
grep session cookies.txt
curl -b cookies.txt -c cookies.txt -d "user=test&pass=test" https://yourapp.example/login
grep session cookies.txt
The session id must change.
In production#
Regenerate the session id on every authentication event:
- Initial sign-in. Issue a new session id after verifying credentials.
- Privilege escalation. If the user re-authenticates for admin access, issue a new session.
- Password change. Treat it as a new login. Sign out all other sessions, issue a new session for the current device.
- Account recovery. After a password reset, revoke all sessions and require re-authentication.
Session fixation also applies to CSRF tokens, API keys, and OAuth states. Any secret that can be observed before authentication and reused after must be rotated on login.
Frameworks differ in behavior:
- Django:
login()rotates the session id automatically. - Flask with flask-login: you must call
session.regenerate()or similar. Check the docs. - Express with express-session: call
req.session.regenerate()on login. - Rails:
reset_sessionclears and regenerates the session id.
If you are building custom auth, make session regeneration an explicit step in the login flow. Do not rely on defaults you have not tested.
Preserve unauthenticated state carefully. If the unauthenticated session contained a shopping basket, a redirect URL, or a language preference, copy it to the new session. Do not copy anything security-sensitive (like a half-completed admin operation) unless you understand the implications.
Key points#
- Session fixation lets an attacker plant the session id before the victim signs in, then hijack the authenticated session.
- The fix is to issue a new session id on every authentication event: login, privilege escalation, password change.
- Never reuse a session id across a trust boundary.
- Most frameworks rotate the session id on login automatically. Verify this for yours.
- Preserve unauthenticated state (baskets, redirects) when you regenerate the session. Do not preserve security-sensitive state.