skip to content

What does Django's login() do to the session it finds, and how does that protect a members-only portal against session fixation?

level: middleimportance: should knowfreq 52%

answer

  1. the key a visitor arrived with
  2. anonymous versus someone else's session
  3. keep data, change the key
  4. three underscore-prefixed keys

basics

~20 s

login() cycles the key of an anonymous session, keeping its data, and flushes a session that belonged to another user. A key planted before sign-in therefore never becomes authenticated. It then stores the user id, backend and password hash.

solid answer

~40 s

Session fixation means an attacker gets a victim to use a session key the attacker already knows, then waits for the victim to sign in. Django's `login()` breaks that: if the session holds no `_auth_user_id`, it calls `request.session.cycle_key()`, which issues a new key while keeping the data; if it holds a different user, or the same user with a mismatched password hash, it calls `flush()` and starts empty. Only then does it write `_auth_user_id`, `_auth_user_backend` and `_auth_user_hash`, set `request.user`, rotate the CSRF token and send `user_logged_in`. One edge: when the same user logs in again with a matching hash, `login()` neither cycles nor flushes, because the key was already rotated when that session became authenticated.

code

python · 17 lines
python
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse


class SignInRotatesSessionKey(TestCase):
    def test_key_changes_and_data_survives(self):
        get_user_model().objects.create_user('member42', password='s3cret-pass!')
        session = self.client.session
        session['chosen_class'] = 'spin-0700'
        session.save()
        before = session.session_key

        self.client.post(reverse('login'), {'username': 'member42', 'password': 's3cret-pass!'})

        self.assertNotEqual(self.client.session.session_key, before)
        self.assertEqual(self.client.session['chosen_class'], 'spin-0700')

go deeper

for a junior

Recall that Django's login() gives the visitor a new session key, and that this is what stops a planted key from becoming a logged-in session.

for a middle

Explain the three branches: cycle_key() for an anonymous session, flush() for another user's session, nothing for the same user, and name the three keys written afterwards.

for a senior

Point out what login() does not protect: hand-rolled session writes, later privilege elevation and custom remember-me cookies, and test the key change explicitly.

for a principal

Decide where your product needs extra rotations beyond login(), such as step-up authentication, and make them part of the auth design rather than per-view habits.

## The attack in one paragraph In a **session fixation** attack the attacker does not steal a session key; they plant one. They obtain a valid anonymous key from the site, get it into the victim's browser, and wait. If the site keeps using the same key after the victim signs in, the attacker's copy of the key is now an authenticated session. The defence is to issue a new key at the moment of authentication. Django builds that into `django.contrib.auth.login()`, so a gym portal that uses `login()` or `LoginView` gets it without extra code. ## The three branches inside login() `login()` looks at the session before it writes anything: | Session state on arrival | What login() does | Data kept? | |---|---|---| | No `_auth_user_id` (anonymous visitor) | `cycle_key()` - new key, old record deleted | yes | | Authenticated as a **different** user, or same user with a mismatched `_auth_user_hash` | `flush()` - data cleared, record deleted, new key on save | no | | Authenticated as the **same** user with a matching hash | nothing | yes | The first branch is the fixation defence. `cycle_key()` creates a fresh key, copies the in-memory data across and deletes the record stored under the old key, so the planted key now points at nothing. Keeping the data is deliberate: the Django docstring notes that data set during the anonymous session is retained, which lets a visitor who picked a class before signing in keep that choice. The second branch protects the next person at a shared terminal. If one member signed in and another signs in on the same browser, the second member must not inherit anything from the first, so the session is flushed. The third branch is the edge interviewers like: re-authenticating as the same user does not rotate the key. That is safe because the key was already rotated when the session first became authenticated. ## What login() writes afterwards 1. `_auth_user_id` - the user's primary key serialised as a string. 2. `_auth_user_backend` - the dotted path of the backend that verified the user. 3. `_auth_user_hash` - `user.get_session_auth_hash()`, an HMAC of the password field keyed by `SECRET_KEY`. Then it sets `request.user`, calls the CSRF module's `rotate_token()` so the token used before login is not reused afterwards, and sends the `user_logged_in` signal. By default a receiver on that signal updates the user's `last_login`. ## What this does not cover - **Code that bypasses `login()`.** Writing `request.session['_auth_user_id']` by hand, or building your own "remember me" cookie, skips the rotation entirely. - **Privilege changes after login.** Stepping up to a staff area or confirming a second factor does not call `login()` again; if you want a new key at that point, call `request.session.cycle_key()` yourself. - **Password changes.** Those are handled by the session hash and `update_session_auth_hash()`, not by `login()`. ## Where each call sits in the request For a sign-in handled by `LoginView`: 1. The POST arrives with the anonymous session cookie the visitor already had. 2. `AuthenticationForm.clean()` calls `authenticate()`; nothing in the session changes yet. 3. `form_valid()` calls `login()`, which cycles the key and writes the three auth keys. 4. `SessionMiddleware` sees the modified session on the way out and sets a cookie with the **new** key. 5. The browser replaces its cookie; the old key now matches no stored record. The attacker who planted the original key is left holding a key with no stored record behind it, so their next request is simply anonymous. ## Async views `alogin()`, added in Django 5.0, runs the same three branches with the async session methods (`ahas_key()`, `acycle_key()`, `aflush()`, `aset()`), so the protection is identical in an async view. ## How to verify it - In a test, read `client.session.session_key` before and after posting to the login view and assert they differ. - Assert that data placed in the anonymous session is still present after login. - Log in as member A, then as member B on the same client, and assert A's data is gone.

  • Why does login() keep the anonymous session's data instead of flushing it?
    Fixation is defeated by changing the key, not by deleting data. `cycle_key()` issues a new key and deletes the old record, so the attacker's planted key becomes useless, while the visitor's own choices such as a selected class or a basket survive sign-in. Flushing is reserved for the case where the session belonged to a different user.
  • When would you call request.session.cycle_key() yourself?
    When the privilege attached to a session rises without a fresh `login()`, for example after a second factor is confirmed or when a member enters a staff-only area that re-prompts for a password. `login()` only rotates at the moment it runs, so later elevations need an explicit rotation.

Checking in at the gym desk swaps the paper wristband you walked in with for a new one: your locker number carries over, but a copy of the old band no longer opens anything.

saying these in an interview costs you the question

  • login() always flushes the session, so pre-login data is lost
  • login() keeps the same session key and only adds the user id
  • login() rotates the session key even when the same user signs in again
  • Session fixation is prevented by the session cookie's HttpOnly flag
  • login() stores the user's password hash itself in the session