Chapter 7 · Security

7.1 The CSRF secret and its token

The CSRF secret is the random string CsrfViewMiddleware keeps for each browser, in the cookie "csrftoken" or in the session, and its token is a fresh mask followed by the secret enciphered with it, made by get_token. Where the secret is made, how it is masked, how CsrfViewMiddleware.process_request reads it and CsrfViewMiddleware.process_response sends it back, and what rotate_token and CSRF_USE_SESSIONS change.

The CSRF check compares what the browser holds, which another site’s page can make it send but cannot read, with what the site’s own page carries. Both come from one secret. Three constants fix their shape: CSRF_SECRET_LENGTH, 32; CSRF_TOKEN_LENGTH, twice that; and CSRF_ALLOWED_CHARS, the ASCII letters and digits, 62 characters in which both are written. A fourth, CSRF_SESSION_KEY, "_csrftoken", is the key the secret is kept under in a session.

During a request the secret lives in HttpRequest.META under the key "CSRF_COOKIE", whichever store it came from, beside a flag, "CSRF_COOKIE_NEEDS_UPDATE", that says it is to be sent back with the response. get_token, rotate_token, process_request and process_response read and set these two keys; the CSRF check does not, and reads the secret from its store again.

A new secret

_get_new_csrf_string draws 32 characters of CSRF_ALLOWED_CHARS with get_random_string, which takes each from the secrets module (Secret keys and django.utils.crypto). _add_new_csrf_cookie puts such a string in "CSRF_COOKIE" and sets the flag. It is called in three places: by get_token when the request has none, by process_request when the cookie is malformed, and by rotate_token.

get_token and the mask

get_token returns a token for the request’s secret. If the request has a secret, it reuses it and sets the flag all the same, so that, a comment says, the cookie is sent again and its expiry renewed; if not, it makes one. Either way it masks the secret anew and returns the result.

So a call of get_token makes the response carry the secret. Its docstring names the side effect, the cookie and "Cookie" in "Vary", and advises calling it lazily, as the CSRF context processor does: that processor is in every RequestContext and calls get_token only when a template uses "csrf_token" (The context: Context, RequestContext and context processors). {% csrf_token %} writes the token into the hidden input "csrfmiddlewaretoken" (The built-in tags).

_mask_cipher_secret draws a mask, a second random string of 32 from the same characters, and enciphers the secret position by position: the index in CSRF_ALLOWED_CHARS of the secret’s character, plus the mask’s, modulo 62, is the index of the cipher’s. The token is the mask followed by the cipher. _unmask_cipher_token splits a token at 32 and subtracts the mask’s index from the cipher’s; a difference below zero is used as it is, which a comment there calls fine: a negative index counts from the end of the string, and so comes to the same as modulo 62.

A run of the example, with the test client making the requests, shows one browser’s secret and the tokens of two pages:

Figure 7.2 One secret and two of its tokens. Each token is a fresh mask and the secret enciphered with it, and unmasking either gives back the secret that the cookie holds.

Reading the secret: process_request

CsrfViewMiddleware._get_secret reads the secret from its store. From the cookie named by CSRF_COOKIE_NAME it gives None when there is none, and otherwise passes the value to _check_token_format, which raises InvalidTokenFormat for a length that is neither 32 nor 64 or a character outside CSRF_ALLOWED_CHARS. With CSRF_USE_SESSIONS it reads the request’s session under CSRF_SESSION_KEY, without that check, and raises ImproperlyConfigured if the request has no session: the message says SessionMiddleware must come before CsrfViewMiddleware in MIDDLEWARE. A value of 64 characters is unmasked, since, a comment says, Django before 4.0 masked the secret before storing it.

CsrfViewMiddleware.process_request calls it on the way in. A secret it finds goes into "CSRF_COOKIE" with the flag left alone, so the browser’s cookie is not sent again unless something asks for a token. A malformed one is replaced with a new secret, flagged, and so the response carries a good cookie whatever the view does. In a run, a post carrying the cookie "short" was still refused, since the check reads the cookie again (The CSRF check: CsrfViewMiddleware.process_view), but its 403 set a new cookie, and the next page’s token was accepted. A masked secret from before 4.0 is replaced only when the cookie is being sent anyway, as the comment there says: a post with such a cookie was accepted and set none, and the next page of the form set the 32 characters.

Sending it back: process_response

CsrfViewMiddleware.process_response does nothing unless the flag is set. Then it calls _set_csrf_cookie and clears the flag. The comment gives the reason: other instances of CsrfViewMiddleware, as when a decorator and the middleware are both used, would otherwise set the cookie again; a rotate_token called after it, in another middleware’s process_response, sets the flag again, and a later instance still honours it.

CsrfViewMiddleware._set_csrf_cookie sets the cookie with the secret and these settings, then adds "Cookie" to "Vary", since, its comment says, the content varies with the cookie:

Table 7.3 The settings of the CSRF cookie, with their defaults from django/conf/global_settings.py.
setting default attribute
CSRF_COOKIE_NAME default "csrftoken" attribute the cookie’s name
CSRF_COOKIE_AGE default 52 weeks, in seconds attribute Max-Age, and Expires from it
CSRF_COOKIE_DOMAIN default None: the host that set it attribute Domain
CSRF_COOKIE_PATH default "/" attribute Path
CSRF_COOKIE_SECURE default False attribute Secure
CSRF_COOKIE_HTTPONLY default False attribute HttpOnly
CSRF_COOKIE_SAMESITE default "Lax" attribute SameSite

With CSRF_USE_SESSIONS it sets no cookie and leaves "Vary" to SessionMiddleware, which adds "Cookie" when the session was read; it stores the secret in the session under CSRF_SESSION_KEY only when the session holds a different value, so a page that reuses the secret leaves the session unmodified. In a run of the example with CSRF_USE_SESSIONS true and the sessions kept in signed cookies, the first page set only the session’s cookie, and later pages, whose tokens came from the same secret, set none.

CSRF_USE_SESSIONS

CSRF_USE_SESSIONS, false by default, moves the secret from its own cookie into the session: _get_secret reads it there, _set_csrf_cookie writes it there, and SessionMiddleware must stand before CsrfViewMiddleware. The secret is then part of the session’s data, not a cookie of its own. Django’s documentation calls the cookie safe and the session common practice in other frameworks, and so sometimes demanded by auditors; keeping it out of the session by default lets a site protect the forms of visitors who have no session.

The token from a script: X-CSRFToken

A script that posts can send the token in the header X-CSRFToken in place of the form field, which the check reads from HttpRequest.META under CSRF_HEADER_NAME, "HTTP_X_CSRFTOKEN" by default. It can take the value from the cookie, which holds the secret itself, or from a page’s hidden input, which holds a token: _check_token_format admits both lengths, and in a run a post with either was accepted. The documentation calls the cookie the recommended source, and in a note recommends a masked token, against BREACH.

A script can read the cookie because CSRF_COOKIE_HTTPONLY is false by default. The documentation says HttpOnly would give no practical protection: a script that can read the cookie already runs on the site’s own domain. With it true, or with CSRF_USE_SESSIONS, the token must come from the page. A page that never asks for a token may set no cookie; ensure_csrf_cookie calls get_token for its view so that it does, and requires_csrf_token makes a token available to a view’s template without refusing anything (A middleware as a decorator).

rotate_token

rotate_token replaces the request’s secret with a new one, flagged, so the response carries it and every token made from the old secret stops matching. login and alogin in django.contrib.auth call it once the user is in the session (The user of a request: login, logout and AuthenticationMiddleware). The documentation names the consequence: a form rendered before the login carries a token of the old secret and is refused, as after the back button or a login in another tab.