skip to content

In Django, how do set_signed_cookie() and get_signed_cookie() work, and what does get_signed_cookie() do when the cookie is missing, tampered or too old?

level: middleimportance: should knowfreq 40%

answer

  1. response writes, request reads
  2. default= suppresses errors
  3. KeyError versus BadSignature
  4. two different max_age values
  5. 6.1 salt derivation

basics

~10 s

response.set_signed_cookie() signs the value with a TimestampSigner bound to the cookie name. request.get_signed_cookie() raises KeyError if absent and BadSignature or SignatureExpired if invalid, unless a default is given.

solid answer

~40 s

`HttpResponse.set_signed_cookie(key, value, salt="", **kwargs)` signs the value with `SIGNING_BACKEND` (a `TimestampSigner` by default) using a key derived from `SECRET_KEY` and a salt derived from the cookie name plus `salt`, then calls `set_cookie()` with the remaining arguments. `HttpRequest.get_signed_cookie(key, default=..., salt="", max_age=None)` reverses it. With no `default`, a missing cookie raises `KeyError`, a tampered one `BadSignature`, and one older than `max_age` `SignatureExpired`; with a `default`, all three return it. The `max_age` passed when setting is the browser lifetime; the `max_age` passed when reading is a server-side check on the signed timestamp, and only that one can be trusted. In Django 6.1 the salt derivation is unambiguous by default, so cookies signed under the old scheme fail unless `SIGNED_COOKIE_LEGACY_SALT_FALLBACK = True`.

code

python · 25 lines
python
from datetime import timedelta

from django.shortcuts import redirect, render

UNITS_MAX_AGE = timedelta(days=180)


def set_units(request):
    response = redirect("dashboard")
    response.set_signed_cookie(
        "units",
        request.POST.get("units", "metric"),
        salt="prefs.units",
        max_age=int(UNITS_MAX_AGE.total_seconds()),  # browser lifetime
        httponly=True,
        samesite="Lax",
    )
    return response


def dashboard(request):
    units = request.get_signed_cookie(
        "units", default="metric", salt="prefs.units", max_age=UNITS_MAX_AGE  # server check
    )
    return render(request, "dashboard.html", {"units": units})

go deeper

for a junior

Recall that set_signed_cookie() is on the response and get_signed_cookie() on the request, and that the same salt must be used on both.

for a middle

Explain the error behaviour with and without default, and the difference between the browser max_age and the server-checked max_age.

for a senior

Diagnose upgrade breakage from the 6.1 salt derivation, use SIGNED_COOKIE_LEGACY_SALT_FALLBACK only transitionally, and keep a timestamping SIGNING_BACKEND.

for a principal

Decide what may live in signed cookies versus server-side state, given readability, request size and the lack of per-user revocation.

## The two methods Django splits the signed-cookie API across the request and the response: - `HttpResponse.set_signed_cookie(key, value, salt="", **kwargs)` signs `value` and then calls the ordinary `set_cookie(key, signed_value, **kwargs)`, so `max_age`, `expires`, `path`, `domain`, `secure`, `httponly` and `samesite` work as usual. - `HttpRequest.get_signed_cookie(key, default=RAISE_ERROR, salt="", max_age=None)` reads `request.COOKIES[key]`, verifies it and returns the original string. The value is **readable but tamper-evident**: a user can see it in the browser, but cannot change it without the read failing. ## What gets signed, and with what Both methods go through `signing.get_cookie_signer()`: - the signer class comes from the `SIGNING_BACKEND` setting, default `django.core.signing.TimestampSigner`, so every signed cookie carries a signed timestamp; - the key is derived from `SECRET_KEY` with a cookie-specific prefix, and `SECRET_KEY_FALLBACKS` are mapped the same way, so rotated keys still verify; - the salt is derived from the cookie **name** and the optional `salt` argument. Binding the salt to the name means a value signed for cookie `units` does not verify if copied into cookie `plan`, and the cookie-specific key keeps cookie values from verifying as `signing.dumps()` tokens. ## Reading: missing, tampered, expired | Situation | No `default` given | `default` given | |---|---|---| | Cookie absent | `KeyError` | returns `default` | | Signature does not match | `BadSignature` | returns `default` | | Older than `max_age` | `SignatureExpired` (a `BadSignature` subclass) | returns `default` | | Valid | the original string | the original string | In a view you almost always want `request.get_signed_cookie("units", default=None, max_age=...)` and to treat `None` as no preference. Without a default, an unhandled `KeyError` or `BadSignature` becomes a 500 for anyone whose cookie is missing or stale. ## Two different max_age values This is the part candidates most often blur: 1. `set_signed_cookie(..., max_age=3600)` goes to `set_cookie()` and becomes the cookie's `Max-Age` attribute. It tells the **browser** when to discard the cookie. A client can ignore it or replay the cookie later. 2. `get_signed_cookie(..., max_age=3600)` is checked on the **server** against the timestamp signed into the value. It cannot be bypassed without breaking the signature. If only the first is set, a captured cookie remains valid on the server forever. When age matters, pass `max_age` on the read side. ## Django 6.1: the unambiguous salt derivation Historically the cookie salt was the cookie name concatenated with the `salt` argument, so (`"ab"`, `"c"`) and (`"a"`, `"bc"`) collided and a cookie could be accepted in the wrong context (CVE-2026-6873). The fix prefixes the salt with a version and the salt's length: `django.http.cookies.v2:<len(salt)>:<salt><name>`. | Release | New cookies signed with | Legacy cookies accepted by default | |---|---|---| | 5.2.15 and 6.0.6 (security releases) | the new derivation | yes, `SIGNED_COOKIE_LEGACY_SALT_FALLBACK` defaults to `True` | | 6.1 | the new derivation | no, the setting defaults to `False` and is deprecated | | 7.0 (planned) | the new derivation | never; the setting is removed | So on 6.1 a cookie signed by an unpatched older Django fails with `BadSignature`, or silently returns your `default`. Setting `SIGNED_COOKIE_LEGACY_SALT_FALLBACK = True` makes Django retry with the legacy salt after a signature failure, though not after `SignatureExpired`. The transitional setting is best kept only until old cookies have aged out. ## Common mistakes - **Different salts on write and read.** A `salt=` on `set_signed_cookie()` but not on `get_signed_cookie()` (or the reverse) makes every read fail, which with a `default` looks like the preference was never saved. - **Reading with `request.COOKIES` directly.** That returns the raw `value:timestamp:signature` string without verifying anything. - **No `default` in a view.** A user with a stale or edited cookie gets a 500 instead of the fallback behaviour. - **Signing structured data by hand-rolled string joins.** `set_signed_cookie()` signs a string; serialise a small structure deliberately (for example as JSON) and remember every byte travels on every request. ## When not to use a signed cookie - when the value must stay private: it is readable; - when it grows large: every request carries it; - when it must be revocable per user: a signed cookie is valid until it expires or the key changes. For anything beyond a small preference or marker, server-side session data is usually the better home.

  • Can you catch SignatureExpired separately when reading a signed cookie?
    Yes, by not passing `default`. `get_signed_cookie()` then lets `KeyError`, `SignatureExpired` and `BadSignature` propagate, and you catch them in that order. Passing `default` collapses all three into the same return value, which is usually what a preference cookie wants.
  • After moving to Django 6.1, some users' preference cookies are ignored. How do you confirm and handle it?
    Cookies signed by an unpatched pre-5.2.15/6.0.6 Django use the legacy name-plus-salt derivation, which 6.1 rejects by default, so `get_signed_cookie()` returns the default. Temporarily set `SIGNED_COOKIE_LEGACY_SALT_FALLBACK = True`; once they are re-set or expired, remove it. It is deprecated and gone in 7.0.
  • Does changing SIGNING_BACKEND to Signer affect get_signed_cookie(max_age=...)?
    Yes, badly. The cookie signer is built from `SIGNING_BACKEND`, and `get_signed_cookie()` always passes `max_age` to its `unsign()`, even when it is `None`. The default `TimestampSigner` accepts that argument; plain `Signer.unsign()` does not, so every signed-cookie read would raise `TypeError`. Keep the default timestamping backend, which is also what makes server-side expiry possible.

saying these in an interview costs you the question

  • The max_age passed to set_signed_cookie() is enforced by the server when reading.
  • get_signed_cookie() returns None when the cookie is missing.
  • A signed cookie hides its value from the user.
  • Copying a signed value into another cookie name still verifies.
  • SECRET_KEY_FALLBACKS is what accepts cookies signed with the legacy salt derivation.