Session Hijacking and Fixation: Why Django Rotates Your Session Key on Login — and How a Hand-Rolled Auth Flow Undoes It
Django Security Series — Post 12 | Series III: Authentication & Session
OWASP A07:2021 — Identification & Authentication Failures | Reading time: ~19 min
🧪 Run it yourself. This attack ships as a runnable lab in django-security-lab: two login views and a session the attacker fixes in advance. The hand-rolled view authenticates the user by writing to
request.sessiondirectly — so the pre-login session id is never rotated, the attacker's known cookie becomes the victim's authenticated session, and a flag only the victim can see falls into the attacker's hands. Theauth.login()view rotates the key on login, and the attacker is left holding an empty, anonymous session. Reproduce both from the command line withcurl.
Post 11 was about the attacker at the door, guessing. This one is about the attacker who never guesses at all. Authentication proves who you are exactly once, at login; from then on, every request you make is trusted because it carries a small opaque value — the session cookie — that stands in for that proof. Steal it, or arrange in advance to know it, and you are the user. No password, no second factor, nothing to brute-force. The credential that matters after login is not the password. It is the session id, and most developers never think about it because Django manages it for them.
That is the whole tension of this post: Django manages the session id well — it rotates it at exactly the right moment, it marks the cookie HttpOnly by default, it gives you the flags to lock it to HTTPS — and the vulnerability is almost always something a developer did to step outside that management. This is the counter-intuitive part I want to land early: for most of this series the failure is a control Django doesn't provide (it won't throttle your login, it won't sanitise your HTML). Here the failure is a control Django already provides, silently, that a developer manages to switch off without realising it.
I learned to respect the session id building the password-reset flow for Petição Brasil. Resetting a password is the one moment you have to assume the account may already be compromised — that is why the user is resetting it — so the new password is not enough. I had to decide what happens to every session that was already open, and I chose to walk the whole session store, delete every session bound to that user, flush the current one, and refuse to auto-login afterwards. Only once I had written that did the model click: the reset does not "change the password," it revokes the sessions, because on a session-based app the live sessions are the credential and the password is just how you mint new ones. This post is about the two ways an attacker gets one of those sessions — stealing a live one (hijacking) or planting a known one before login (fixation) — and why the defence is mostly a matter of not undoing what Django does for you.
The Attack: What It Is and How It Works
There are two shapes to this attack, and they differ in when the attacker obtains the session id.
Session hijacking takes a session that is already live. The victim logs in, Django hands their browser a sessionid cookie, and the attacker gets a copy of that value after the fact. The classic route is the network: on unencrypted HTTP, the cookie travels in cleartext on every request, and anyone sharing the wire — the same coffee-shop Wi‑Fi, a compromised router, an ISP-level tap — reads it straight off the packets. The other route is cross-site scripting (Post 2): a script running on your origin can read document.cookie, and if the session cookie is reachable from JavaScript, the script exfiltrates it to an attacker-controlled server. Either way, the attacker replays the stolen value as their own cookie and the server, which has no way to tell one browser from another, serves them the victim's account. The session id was minted honestly; it was copied dishonestly.
Session fixation inverts the timeline: the attacker chooses the session id before the victim authenticates. Because a web app will happily create a session for an anonymous visitor — to hold a cart, a locale, a CSRF secret — an attacker can obtain a valid, empty session id of their own, then trick the victim's browser into adopting that same id (a crafted link that sets the cookie, an XSS write to document.cookie, a Set-Cookie on a shared subdomain). The victim then logs in normally. If the application authenticates the session it was handed — promoting that same id from anonymous to authenticated — the attacker's pre-chosen id is now a logged-in session for the victim, and the attacker, who has known the value all along, simply uses it. The victim typed their own password into their own browser and still handed their account to someone else.
The pivot that defeats fixation is trivial to state and easy to get wrong: rotate the session id at the privilege boundary. The moment an anonymous session becomes an authenticated one, throw away the old id and issue a fresh, random one. Any id the attacker fixed in advance is now stale — it was never promoted, so it stays anonymous — and the value the victim's browser now carries is one the attacker has never seen. Hijacking and fixation converge on the same defensive idea from opposite directions: the session id must be unpredictable to the attacker and unknowable to them across the login event, and it must never travel or rest anywhere they can read it.
What makes this class quietly dangerous is that none of it involves malformed input. There is no payload, no injected syntax, no boundary to escape. Every request the attacker sends is a perfectly well-formed request carrying a perfectly valid session cookie. The server behaves exactly as designed. The defect is not in what the request contains; it is in whether the id in that cookie should still be trusted — a question the application answers implicitly, in the code it wrote around login, and usually never revisits.
Real-World Incidents
Firesheep (2010)
In October 2010, developer Eric Butler released Firesheep, a Firefox extension he demonstrated at the ToorCon security conference, and turned an abstract warning into a party trick. Major sites of the era — Facebook, Twitter, Flickr, and many others — authenticated the login over HTTPS but then served the rest of the session over plain HTTP, so the session cookie was transmitted in cleartext on every subsequent request. Firesheep sat on an open Wi‑Fi network, watched for those cookies, and presented the sidejackable accounts of everyone nearby in a one-click sidebar: double-click a name and you were logged into that stranger's account. It required no skill and no exploit code — the cookies were simply there for the taking on the shared wire.
Firesheep's contribution was not a new technique; session sidejacking was well understood. It was that it made the risk undeniable and put it in the hands of anyone, which is exactly what finally moved the industry. In the two years that followed, the major platforms flipped to HTTPS for the entire session rather than just the login form, and the Secure cookie flag — which tells the browser never to send the cookie over plain HTTP — went from an obscure option to a baseline. In MITRE ATT&CK terms the technique is T1539 — Steal Web Session Cookie: the adversary captures a session cookie and reuses it to authenticate as the victim, side-stepping the credential and any multi-factor prompt entirely, because the session cookie is issued after those checks have already passed.
The regulatory weight of a hijacked session is easy to underplay because nothing is "breached" in the database sense — no dump, no exfiltrated table. But an attacker riding a live session reads and acts on whatever personal data that account can reach, and under Brazil's LGPD (Lei Geral de Proteção de Dados, the country's general data-protection law) Article 48, the controller of a database that suffers a security incident likely to create relevant risk to data subjects must notify the national authority (ANPD) and the affected users. A session-hijacking incident that exposes personal data is exactly that kind of event — and, tellingly, the controls that would have prevented it (the Secure flag, HTTPS everywhere, HttpOnly to blunt cookie theft via XSS) are all one-line settings the developer either set or did not. The compliance failure and the missing cookie flag are the same omission seen from two sides.
Source: Eric Butler — Firesheep (2010)
Django's Default Protections
Django's session framework is one of the strongest defaults in the framework, and the fixation defence in particular is something most developers benefit from without ever knowing it exists.
Session-key rotation on login. When you call django.contrib.auth.login(request, user), Django rotates the session key. Internally, login() inspects the current session: if it already belongs to a different authenticated user it flushes it entirely; otherwise — the common case, an anonymous session becoming authenticated — it calls request.session.cycle_key(), which generates a brand-new random session key while carrying the existing session data across. Either way, the old id is discarded. This is precisely the anti-fixation pivot described above — and it happens automatically, on the one line every Django tutorial tells you to write. An attacker who fixed a session id before login finds that, the instant the victim authenticates, the id they planted is orphaned: it was never promoted, and the victim's browser now holds a fresh id the attacker cannot predict. login() also calls rotate_token() to cycle the CSRF secret at the same boundary, for the same reason.
Cookie flags that keep the id out of reach. Django's session cookie is HttpOnly by default (SESSION_COOKIE_HTTPONLY = True), so the cross-site-scripting theft route — a script reading document.cookie — is closed out of the box; the session cookie simply is not visible to JavaScript. SESSION_COOKIE_SAMESITE defaults to 'Lax', which keeps the cookie off most cross-site requests. And SESSION_COOKIE_SECURE, while not on by default, is the one flag that pins the cookie to HTTPS so a Firesheep-style cleartext capture becomes impossible.
Server-side sessions you can revoke. With the default database backend (or the cache backend), the session data lives on the server and the cookie carries only an opaque key. That indirection is what makes a session revocable: deleting the server-side record — as Petição Brasil's password reset does for every session bound to a user — instantly invalidates the cookie, because the key now points at nothing. It is also what keeps the session contents off the client entirely.
What Django does not do for you:
SESSION_COOKIE_SECUREisFalseby default. Django will not force your session cookie onto HTTPS, and it cannot safely default it toTrue: the development server runs over plain HTTP, where aSecurecookie is never sent — so a safe-in-production default would silently break login the moment you ranrunserver. Django ships the permissive value and leaves the hardening to you, gated onDEBUG=Falsethe way this blog does it; forgetting is the modern echo of the Firesheep gap. (SESSION_COOKIE_HTTPONLYandSESSION_COOKIE_SAMESITEcan default to safe values precisely because they still work over plain HTTP, so they cost nothing in development.)- It cannot rotate a key you rotate around. The
cycle_key()protection lives insidelogin(). A hand-rolled auth flow that marks a session authenticated by writing torequest.sessiondirectly, or an "impersonate user" / "switch account" feature that changes who the session belongs to without callinglogin(), never triggers the rotation — and reopens fixation exactly where Django had closed it. - The signed-cookie backend puts the session in the client's hands. Setting
SESSION_ENGINE = 'django.contrib.sessions.backends.signed_cookies'moves the entire session payload into the cookie itself. It is signed (tamper-evident) but not encrypted (readable by anyone who holds the cookie), and — because there is no server-side record — it cannot be invalidated: a logout or password reset cannot revoke a signed-cookie session that an attacker has already copied. It trades away the two properties this post cares about most.
Vulnerable Pattern: What NOT to Do
Pattern 1 — A hand-rolled login that never rotates the session
The canonical footgun is authenticating a user without going through login(). It usually appears when someone wants "more control" over the login response, or is porting an auth flow, and reconstructs by hand what login() does — but leaves out the part they could not see, the key rotation:
# INSECURE — authenticates by writing to the session directly, so the
# pre-login session key is never rotated (session fixation).
from django.contrib.auth import authenticate
from django.http import HttpResponse
def login_view(request):
user = authenticate(
username=request.POST.get('username'),
password=request.POST.get('password'),
)
if user is None:
return HttpResponse('invalid credentials', status=401)
# DANGER: these are the writes login() makes — but NOT its cycle_key()
# rotation. The session id the browser arrived with is promoted in place.
request.session['_auth_user_id'] = str(user.pk)
request.session['_auth_user_backend'] = 'django.contrib.auth.backends.ModelBackend'
request.session['_auth_user_hash'] = user.get_session_auth_hash()
return HttpResponse(f'logged in as {user.username}')
The code "works" — the user is logged in, request.user resolves on the next request, every test that only checks whether login succeeds passes. What it silently drops is the rotation. An attacker who fixed the session id before this call now holds an authenticated session, because the id the victim arrived with was promoted in place instead of being replaced. Nothing here looks like a vulnerability; it looks like a login view. The bug is the line that is not there.
Pattern 2 — A session cookie that is not pinned to HTTPS
# INSECURE (settings.py) — the session cookie is allowed over plain HTTP
# on an HTTPS site, so a network attacker can capture it (the Firesheep gap).
SESSION_COOKIE_SECURE = False # ← cookie sent over http:// too
# SESSION_COOKIE_HTTPONLY defaults True, SESSION_COOKIE_SAMESITE defaults 'Lax' —
# but SECURE is the one that is off unless you turn it on.
This is not a code bug at all; it is a default left in place. On a site served over HTTPS, leaving SESSION_COOKIE_SECURE = False means the browser will still attach the session cookie to any accidental http:// request — a mistyped scheme, a mixed-content asset, an attacker who strips TLS on an open network — and that request carries the id in cleartext. It is exactly the surface Firesheep harvested, a decade later and one setting away.
Pattern 3 — An "impersonate" or "switch user" feature that reuses the session
# INSECURE — staff impersonation that changes who the session belongs to
# without rotating the key.
@user_passes_test(lambda u: u.is_staff)
def impersonate(request, user_id):
target = get_object_or_404(User, pk=user_id)
# DANGER: the privilege boundary is crossed (staff → target user) but the
# session id is reused, so a fixed or shared id survives the transition.
request.session['_auth_user_id'] = str(target.pk)
return redirect('dashboard')
Impersonation is a privilege boundary just like login, and it deserves the same rotation. Reassigning _auth_user_id in place means the session that was "staff Alice" becomes "user Bob" under the same id — so any earlier fixation of that id, or any stale copy of it, now rides along into Bob's account. The correct version routes the switch through login() (which rotates) rather than mutating the session by hand.
Secure Implementation: The Django Way
Rule 1 — Authenticate through login(), always
The single most important rule is also the easiest: never mark a session authenticated by hand. Call django.contrib.auth.login(), and let it rotate the key for you:
# SECURE — login() cycles the session key on the privilege change, so a fixed
# pre-login session id cannot survive into the authenticated session.
from django.contrib.auth import authenticate, login
from django.http import HttpResponse
def login_view(request):
user = authenticate(
username=request.POST.get('username'),
password=request.POST.get('password'),
)
if user is None:
return HttpResponse('invalid credentials', status=401)
login(request, user) # cycle_key(): new random session id, data preserved
return HttpResponse(f'logged in as {user.username}')
The whole fixation defence is the one call. login() regenerates the session key, migrates the session data to the new id, sets the _auth_* keys correctly (including the session-auth hash that ties the session to the password, so a password change can invalidate it), and rotates the CSRF token. Any auth-adjacent transition that crosses a privilege boundary — login, impersonation start and stop, step-up authentication — should go through it rather than around it.
Rule 2 — Pin the cookie to HTTPS and keep it out of JavaScript
Set the session cookie flags explicitly in production. HttpOnly and SameSite already default to safe values, but making the intent visible (and turning Secure on) is the difference between "we relied on a default" and "we decided":
# SECURE (settings.py) — production session-cookie hardening
SESSION_COOKIE_SECURE = True # never sent over plain HTTP (closes Firesheep)
SESSION_COOKIE_HTTPONLY = True # unreadable from document.cookie (blunts XSS theft)
SESSION_COOKIE_SAMESITE = 'Lax' # not attached to most cross-site requests
SESSION_COOKIE_SECURE = True is the direct descendant of the Firesheep lesson: with it set, a network attacker never sees the cookie because the browser refuses to transmit it in cleartext. SESSION_COOKIE_HTTPONLY = True (already the default) means that even if an XSS bug slips through (Post 2), the script cannot read the session id out of document.cookie. These are defence in depth for the same asset from two different threats — the network and the injected script.
One caveat completes the HTTPS story for real deployments. Behind a TLS-terminating proxy or CDN (Heroku, Cloudflare, an AWS load balancer), the request that reaches Django arrives over plain HTTP — the proxy already handled the TLS — so request.is_secure() returns False and the HTTPS-aware machinery (SECURE_SSL_REDIRECT, the CSRF referer check) misfires even though the user is on HTTPS. Tell Django to trust the scheme the proxy forwarded with SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https'), but only when the proxy is one you control and that strips or overwrites that header — otherwise a client reaching Django directly could forge it and claim its plain-HTTP request was secure. SESSION_COOKIE_SECURE = True itself stamps the Secure attribute unconditionally, so the cookie flag works behind a proxy regardless; the header is what the surrounding redirect-and-detect logic needs to agree that the connection really is HTTPS.
Rule 3 — Bound the session's lifetime
A session that never expires is a stolen cookie that works forever. Set an age that matches the sensitivity of the app, and decide whether sessions should survive the browser closing:
# SECURE (settings.py) — bound how long a session (and a stolen cookie) stays valid
SESSION_COOKIE_AGE = 60 * 60 * 24 # 24 hours, not the 2-week default
SESSION_EXPIRE_AT_BROWSER_CLOSE = False # set True for shared-machine contexts
Django's default SESSION_COOKIE_AGE is two weeks, which is generous for anything holding personal data. Shortening it caps the window a hijacked or fixated session stays useful. For a genuinely sensitive action, re-authentication (asking for the password again at the boundary) is the stronger control, but a bounded age is the cheap baseline every app should set.
Rule 4 — Keep sessions server-side and revocable
Leave SESSION_ENGINE on a server-side backend (the default database backend, or cache/cached_db), so the cookie carries only an opaque key and the session data — and the power to revoke it — stays on your server. Then use that power at the moments that matter: on password change or reset, invalidate the user's other sessions, exactly as the Petição Brasil reset flow does. Django ties the session to the password hash via a session-auth hash and verifies it on every request (in auth.get_user()), so after a password change the user's other sessions are logged out automatically on their next request, while update_session_auth_hash() keeps the current one alive — but for an immediate, total revocation (the "sign out everywhere" button, or a reset that assumes compromise) deleting the server-side session records is the direct, auditable move. None of this is possible with the signed-cookie backend, which is the concrete reason to avoid it for authenticated sessions.
The Analyst's View
For a CySA+ analyst, session hijacking and fixation are the cleanest case in the series of a single asset — the session identifier — that has to be protected across three different planes at once, and the defence is a stack of controls no one of which is sufficient alone. Rotation on login (cycle_key()) is a preventive control against fixation: it removes the attacker's foreknowledge of the id. The Secure flag plus TLS is a preventive control against network capture: it removes the id from the wire. HttpOnly is a compensating control layered against a different vulnerability class entirely — it does not fix the XSS (Post 2), it just denies the XSS its highest-value target, the session cookie. Read that way, the session cookie is a worked example of defence in depth: the same secret guarded against being predicted, sniffed, and scripted-out, by three independent mechanisms, because any one of them can fail.
The habit the attack rewards is treating the session id as a credential with a lifecycle, not a value that exists. A credential is issued (rotate it at every privilege boundary so the attacker never knows it), transmitted (pin it to a channel they cannot read), stored (server-side, so you can revoke it), and retired (bounded age, and killed on password change). Every control in Secure Implementation slots into one of those four verbs, and the vulnerable patterns are each a place where one verb was skipped — a login that never re-issued, a cookie transmitted in the clear, a signed-cookie session that could not be retired. When you review an auth flow, walk the four verbs against it; the missing one is the finding.
Catching It Automatically
Testing Your Defence
The fixation defence has a property that makes it unusually testable: it is a single observable fact — the session key must change across login(). A test captures the key before authentication and asserts it is different afterward. That one assertion fails against every Pattern 1 hand-rolled login and passes only when the flow goes through login():
# tests/test_session_fixation.py
from django.contrib.auth.models import User
from django.test import TestCase, Client
class SessionFixationTests(TestCase):
def setUp(self):
self.user = User.objects.create_user('victim', password='correct-horse-battery')
def test_session_key_rotates_on_login(self):
"""The fixation defence: the session id must change across login()."""
client = Client()
client.get('/accounts/login/') # establish a pre-login session
before = client.session.session_key
self.assertIsNotNone(before)
client.post('/accounts/login/', {'username': 'victim',
'password': 'correct-horse-battery'})
after = client.session.session_key
self.assertNotEqual(before, after) # fails on a hand-rolled login
def test_fixed_session_id_does_not_survive_login(self):
"""A pre-chosen id must be orphaned once the victim authenticates."""
attacker = Client()
attacker.get('/accounts/login/')
fixed = attacker.session.session_key # the id the attacker knows
victim = Client()
victim.cookies['sessionid'] = fixed # victim adopts the fixed id
victim.post('/accounts/login/', {'username': 'victim',
'password': 'correct-horse-battery'})
# The attacker's known id was never promoted — it is still anonymous.
attacker.cookies['sessionid'] = fixed
response = attacker.get('/accounts/whoami/')
self.assertNotContains(response, 'victim', status_code=200)
The cookie-flag side is just as directly checkable — assert the Set-Cookie header on an authenticated response carries Secure, HttpOnly, and SameSite:
def test_session_cookie_is_hardened(self):
client = Client()
response = client.post('/accounts/login/', {'username': 'victim',
'password': 'correct-horse-battery'})
cookie = response.cookies['sessionid']
self.assertTrue(cookie['secure'])
self.assertTrue(cookie['httponly'])
self.assertEqual(cookie['samesite'], 'Lax')
Scanning It
The SAST tools come up empty here, and for a sharper reason than in the other posts where they miss: there are two flaws and neither is a pattern a rule can match. Bandit reports zero on the lab — not even the hardcoded-test-password noise other labs show, because there is simply no dangerous call anywhere in a login that skips login(). Semgrep's community packs (p/django, p/python, p/owasp-top-ten) report zero too, and the registry/audit tier surfaces only unrelated nits (direct-use-of-httpresponse on the lab's plain HttpResponse views) — nothing about session rotation. And unlike IDOR or mass assignment, no custom rule could rescue it: the fixation flaw is the absence of a cycle_key() call, and a rule can flag a dangerous call but never a missing one. So, as in the brute-force post, this lab ships no custom rule — one here would be theatre.
The detection that does work is one the series has not reached for until now: Django's own deployment scanner. python manage.py check --deploy runs the framework's security system checks against your settings, and it flags the cookie half in as many words:
python manage.py check --deploy
# ?: (security.W012) SESSION_COOKIE_SECURE is not set to True. Using a secure-only
# session cookie makes it more difficult for network traffic sniffers to hijack
# user sessions.
W012 is the Firesheep lesson in Django's own words, and it ships in the framework — no extra tool, no custom rule. It catches what Bandit and Semgrep structurally cannot because the vulnerability is a value (SESSION_COOKIE_SECURE = False), not a call, and check --deploy is a settings-linter, not a code scanner. Run it in CI and it becomes a gate on the whole deployment-security checklist — secure cookies, HSTS, the SSL redirect, DEBUG — for the cost of one command. (It has one blind spot worth naming: it reads settings, so it cannot see a cookie you set by hand with response.set_cookie('t', v) and no secure= — that surface is Post 19's.)
The rotation half has no scanner at all, and that is the honest CySA+ lesson of the post: some controls are verified by a test, not a tool. The session_key-must-change-across-login() assertion in the lab's tests.py is deterministic and fails the instant a hand-rolled login creeps back in; the dynamic other half is the curl probe — fix an id, ride it to the flag on the vulnerable view, watch it die (a 302) on the secure one. The captured check --deploy, Bandit, and Semgrep runs are committed under scans/; tests.py is the runnable proof.
The lesson of session security is the quiet one that closes most of Series III's front-door theme: Django already issues, rotates, and hardens the session id correctly, so the vulnerability is nearly always a developer stepping outside that machinery — a login that skips login(), a cookie flag left at its insecure default, a backend that trades away revocability. Route every privilege boundary through login(), pin the cookie to HTTPS, keep the session server-side, and the crown jewel stays yours.
Post 13 continues Series III with Weak Passwords and Validators, where the failure moves back to the credential itself — the app that accepts 123456, or the user's own email, as a password — and Django's AUTH_PASSWORD_VALIDATORS catch it only when every registration and change path actually calls them.
Further Reading
- django-security-lab — this post's runnable lab (
labs/post_12_session_fixation/), the fix-the-session fixation exploit vs. rotation on login - Django Docs — How to use sessions (session engines, cookie settings, security)
- Django Docs — Authentication: how to log a user in (
login()and session rotation) - Django Docs — Session settings reference (
SESSION_COOKIE_*,SESSION_ENGINE) - OWASP A07:2021 — Identification and Authentication Failures
- OWASP Cheat Sheet — Session Management
- PortSwigger Web Security Academy — Session fixation and related auth mechanisms
- MITRE ATT&CK — T1539: Steal Web Session Cookie
- Eric Butler — Firesheep (2010)
- Web Security for Developers: Real Threats, Practical Defense (Malcolm McDonald) — Chapter 10: Session Hijacking