skip to content

After a Django deploy replaced SECRET_KEY, every emailed signing.dumps() link fails with BadSignature; how does SECRET_KEY_FALLBACKS fix this, and how long must the old key stay?

level: seniorimportance: nice to knowfreq 22%

answer

  1. sign with one, verify with many
  2. list order matters
  3. longest max_age in flight
  4. leaked keys are different

basics

~20 s

Django signs only with SECRET_KEY but verifies against SECRET_KEY, then each SECRET_KEY_FALLBACKS entry. Put the old key first in the fallbacks and keep it at least as long as the longest max_age of outstanding tokens.

solid answer

~40 s

Every signature is an HMAC keyed from `SECRET_KEY`, so replacing the key invalidates all outstanding tokens. `SECRET_KEY_FALLBACKS` (default `[]`) is verification-only: `Signer.sign()` always uses `SECRET_KEY`, while `unsign()` tries `SECRET_KEY` and then each fallback in order and accepts the first match. So the deploy should set the new key and put the old one at the start of `SECRET_KEY_FALLBACKS`. It must stay there at least as long as the longest `max_age` any `loads()` call enforces, 30 days for these links; tokens read without `max_age` have no natural end, so removing the key is a deliberate cut-off. Each fallback adds an HMAC on every failed match, so fallbacks are pruned afterwards. And a leaked key never goes into the fallbacks, because it would keep validating forgeries.

code

python · 7 lines
python
import os

# settings.py, deploy of 2026-09-26
SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]  # new key: signs everything from now on
SECRET_KEY_FALLBACKS = [
    os.environ["DJANGO_SECRET_KEY_PREVIOUS"],  # old key: verify only, remove after 30 days
]

go deeper

for a junior

Recall that changing SECRET_KEY invalidates signed values and that SECRET_KEY_FALLBACKS holds old keys that still verify.

for a middle

Explain the sign-with-one, verify-with-many loop in Signer.unsign() and why the old key goes at the start of the list.

for a senior

Size the retention from the longest max_age in flight, handle tokens read without max_age, prune the list, and refuse to keep leaked keys as fallbacks.

for a principal

Set a project rule linking every signing call to a documented max_age so rotations have a known blast radius and a known end.

## Why a key change breaks every signature Every signature produced by `django.core.signing` is an HMAC whose key is derived from `SECRET_KEY` and the signer's salt. Verification recomputes that HMAC with the key it currently knows. When a deploy replaces `SECRET_KEY`, the recomputed HMAC no longer matches anything signed before the deploy, and every outstanding token fails with `BadSignature`: unsubscribe links, signed cookies, anything else built on signing. Other features that sign with the key, such as sessions and password-reset tokens, are affected in the same way. ## Confirming the diagnosis The symptom pattern is distinctive, and worth checking before changing settings: - the exception is `BadSignature`, not `SignatureExpired`: the tokens are not old, their signatures simply no longer match; - links generated **after** the deploy work, while every link generated **before** it fails; - the failures start at the deploy time across all features that sign, not only one view. A salt change would break one feature; a key change breaks all of them at once. ## How verification uses SECRET_KEY_FALLBACKS `SECRET_KEY_FALLBACKS` is a list, empty by default, of **retired keys that may still verify but never sign**. In `Signer`: 1. `sign()` and `sign_object()` always use `self.key`, which is `SECRET_KEY` unless you pass `key=`; 2. `unsign()` builds the list `[self.key, *self.fallback_keys]` and tries each in order; 3. the first key whose HMAC matches wins and the value is returned; 4. if none matches, `BadSignature` is raised. `signing.loads()` has its own `fallback_keys` argument; left as `None`, it falls back to the setting. `get_cookie_signer()`, used by signed cookies, maps the fallbacks through the same cookie-specific key derivation as the main key. One subtlety: a `Signer(key="custom")` still defaults `fallback_keys` to `SECRET_KEY_FALLBACKS`, so a signer with its own key should pass its own `fallback_keys` explicitly. The correct change is therefore: new value in `SECRET_KEY`, previous value at the **beginning** of `SECRET_KEY_FALLBACKS`. New tokens are signed with the new key immediately; old ones still verify. ## How long the old key must stay The retention period is set by the tokens in flight, not by a fixed rule: | What was signed | How it is read | Minimum retention of the old key | |---|---|---| | `signing.dumps()` unsubscribe link | `loads(max_age=timedelta(days=30))` | 30 days after the rotation | | signed preference cookie | `get_signed_cookie(max_age=...)` | that `max_age` | | any token | `loads()` with no `max_age` | no natural end; removing the key is the cut-off | After the last relevant window passes, remove the old key from the end of the list. Anything still signed with it then fails with `BadSignature`, which for expired content is the right outcome. ## Costs and caveats - **CPU per miss.** Each fallback adds one more HMAC computation to every verification that does not match an earlier key. Django's settings reference warns about this overhead; a list that grows with every rotation slows down every invalid token and every token signed by an old key. - **A leaked key is not rotated, it is revoked.** Putting a compromised key into `SECRET_KEY_FALLBACKS` means forgeries made with it keep verifying. When the reason for rotation is a leak, accept the breakage of outstanding tokens and re-issue what matters. - **Fallbacks must be as secret as the main key.** They validate signatures, so anyone holding one can still forge tokens that verify. - **All instances must agree.** During a rolling deploy some instances sign with the new key while others still run the old settings. Instances on the new settings accept both; instances on the old settings reject new tokens until they are replaced, which is one more reason to put the old key into the fallbacks in the same deploy that introduces the new key. - **Passwords are unaffected.** User password hashes do not use `SECRET_KEY`, so rotation does not lock anyone out of their account. ## A signing-side checklist for a planned rotation 1. Inventory every signing call and its `max_age`, including calls that pass none. 2. Deploy with the new `SECRET_KEY` and the old key first in `SECRET_KEY_FALLBACKS`. 3. Wait out the longest `max_age`; decide explicitly what happens to tokens read without one. 4. Remove the old key and confirm the list stays short. The operational side of generating and distributing keys belongs to the secret-key procedures; from the signing module's point of view, the only questions are which key signs, which keys verify, and for how long.

  • Does Django re-sign old tokens with the new key when they verify against a fallback?
    No. `unsign()` only returns the value; it does not produce a new token. An old unsubscribe link keeps its old signature until it expires. If you want long-lived values moved to the new key, your own code must re-issue them, for example by setting a signed cookie again after a successful read.
  • Why can a Signer created with its own key= still accept signatures from an old SECRET_KEY?
    In Django's source, `Signer` defaults `fallback_keys` to `settings.SECRET_KEY_FALLBACKS` whenever the argument is `None`, even when `key=` is custom. So a signer meant to use only a dedicated key will also try the project's fallback keys. Pass `fallback_keys=[]`, or that key's own retired values, to keep it isolated.

saying these in an interview costs you the question

  • Keys in SECRET_KEY_FALLBACKS are also used to sign new tokens.
  • Rotating SECRET_KEY forces every user to reset their password.
  • A leaked key should go into SECRET_KEY_FALLBACKS so existing links keep working.
  • Fallback keys can stay forever because they cost nothing at runtime.
  • Django re-signs a token with the new key whenever a fallback verifies it.