In Django's django.core.signing, what does the salt argument do, and why is reusing one salt across features dangerous?
answer
- a namespace, not a secret
- mixed into the HMAC key
- default salts per call
- cross-feature token replay
basics
~20 sThe salt is mixed into the key the HMAC uses, so a value only verifies under the salt it was signed with. Sharing one salt lets a token issued for one feature be replayed against another.
solid answer
~40 sIn `django.core.signing` the salt namespaces signatures. `Signer` passes it into `salted_hmac`, which derives the HMAC key from the salt plus `SECRET_KEY`, so the same value signed under two salts gets two unrelated signatures and verifying under the wrong salt raises `BadSignature`. The salt is not a secret. Defaults are per call: `dumps()`/`loads()` use `django.core.signing`, `Signer()` uses `django.core.signing.Signer`, and `TimestampSigner()` uses `django.core.signing.TimestampSigner`. The danger is that every feature relying on the same default shares one namespace: a token a user legitimately gets for one purpose, say a share link carrying `{"id": 42}`, then verifies in another view expecting `{"id": ...}` for something else. I give each purpose a fixed literal salt, such as `reports.share`, and treat the salt as part of the feature's contract.
code
python · 11 linesfrom django.core import signing
share_token = signing.dumps({"id": 42}, salt="reports.share")
# The invoice view verifies under its own namespace:
try:
signing.loads(share_token, salt="billing.invoice-download")
except signing.BadSignature:
print("rejected: signed for a different purpose")
assert signing.loads(share_token, salt="reports.share") == {"id": 42}go deeper
Recall that the same salt must be passed when signing and when verifying, and that a mismatch raises BadSignature.
Explain that the salt feeds the HMAC key derivation, list the default salts of dumps(), Signer and TimestampSigner, and state that the salt is not secret.
Walk through a cross-feature replay caused by shared default salts and show the fix: one literal salt per purpose, plus a purpose field and an ownership check.
Treat salts as part of a project convention: a registry of purposes, review rules for new signing calls, and salt changes as a scoped revocation tool.
## What a salt is here In `django.core.signing`, a **salt** is a string that selects a **signing namespace**. Two signers with the same `SECRET_KEY` but different salts produce unrelated signatures for the same value, and neither accepts the other's output. It is the module's answer to one question: how do you stop a value signed for purpose A from being accepted by code that verifies purpose B? Unlike `SECRET_KEY`, the salt does **not** need to stay secret. An attacker who knows it still cannot produce a valid signature without the key. ## How the salt enters the signature `Signer.signature()` calls `django.utils.crypto.salted_hmac()` with the signer's salt (plus a fixed suffix) as the `key_salt`. That function hashes `key_salt + secret` to derive the actual HMAC key, then signs the value with it. So: - the salt changes the **key**, not the value being signed; - changing a single character of the salt produces a completely different signature; - verification recomputes the HMAC with the verifier's salt, so a token signed under another salt raises `BadSignature`, exactly as if it had been tampered with. ## The default salts If you pass no salt, each entry point uses its own default: | Entry point | Default salt | |---|---| | `signing.dumps()` / `signing.loads()` | `django.core.signing` | | `Signer()` | `django.core.signing.Signer` (module plus class name) | | `TimestampSigner()` | `django.core.signing.TimestampSigner` | | `set_signed_cookie()` / `get_signed_cookie()` | derived from the cookie name and the `salt` argument, with a cookie-specific key | The defaults keep the *entry points* apart from each other, but every feature in a project that calls `signing.dumps()` without a salt shares the single namespace `django.core.signing`. The docstring of `dumps()` in the Django source says it directly: leaving the salt at its default or reusing one across different parts of an application is a security risk. ## The replay a salt prevents Consider two features written by different people: 1. **Report sharing.** A user who owns report 42 creates a share link. The view signs `{"id": 42}` with `signing.dumps()` and no salt. 2. **Invoice download.** Invoice emails carry `signing.dumps({"id": invoice.pk})`, also with no salt, and the download view trusts the signed id instead of checking ownership. Both tokens live in the same namespace and have the same payload shape. A user who owns report 42 can paste their share token into the invoice URL and download **invoice 42**, which belongs to someone else. Nothing was forged: the signature is genuine; it was simply accepted in the wrong context. With `salt="reports.share"` and `salt="billing.invoice-download"`, the invoice view recomputes the HMAC under its own salt, the share token fails with `BadSignature`, and the attack disappears. ## Choosing salts well - **One fixed literal per purpose**: `accounts.email-confirm`, `newsletter.unsubscribe`, `reports.share`. Define it once as a module constant and use it on both sides. - **Do not build salts by concatenating variable parts** without a delimiter you control. Two different inputs can concatenate to the same string, which merges two namespaces. - **Changing a salt invalidates every outstanding token** signed under the old one; treat it like a key change for that feature. - **Defence in depth**: put a purpose field in the payload too, and still check authorization on the object the token names. - **Salt is not a substitute for `max_age`** or for a different key; it separates contexts, nothing more. ## Spotting shared namespaces in review A shared default salt is easy to find mechanically. Search the codebase for `signing.dumps(`, `signing.loads(`, `Signer(` and `TimestampSigner(` calls that pass no `salt=`. For each hit, ask three questions: - **Which purpose is this token for**, and is any other verifier in the project reading tokens from the same namespace? - **Does the payload shape overlap** with another feature's, such as a bare `{"id": ...}`? - **Does the verifying view trust the signed id** instead of re-checking that the current user may act on that object? Any call whose answers are unclear gets its own literal salt. Remember that adding a salt to an existing feature invalidates the tokens already issued for it, so plan it like a small rotation. ## A lesson from Django's own signed cookies Django learned the concatenation lesson itself. Before the fix, `get_signed_cookie()` built its salt as cookie name plus `salt` argument, so the pairs (`"ab"`, `"c"`) and (`"a"`, `"bc"`) shared the namespace `abc`, and a cookie could be accepted in a context other than the one that signed it (CVE-2026-6873). The fix, shipped in the 5.2.15 and 6.0.6 security releases and made strict by default in Django 6.1, derives the salt as `django.http.cookies.v2:<length of salt>:<salt><cookie name>`: the length prefix makes every (name, salt) pair unambiguous.
- If the salt is public, what stops an attacker computing a signature for their own salt and value?The HMAC key is derived from the salt together with `SECRET_KEY`. Knowing the salt gives the attacker one input of the key derivation, not the key. Without `SECRET_KEY` (or a key in `SECRET_KEY_FALLBACKS`) they cannot produce a signature any verifier accepts, whatever salt they choose.
- Why does changing a feature's salt act like revoking all of that feature's tokens?Verification recomputes the HMAC with the current salt. Tokens signed under the old salt were computed with a different derived key, so every one of them now raises `BadSignature`. That is useful as a targeted kill switch for one feature, without rotating `SECRET_KEY` for the whole project.
saying these in an interview costs you the question
- The salt must be kept secret, just like SECRET_KEY.
- Leaving the default salt is fine because the payload is signed anyway.
- A salt adds a random nonce, so each token can be used only once.
- The salt only matters when signing; loads() ignores it.
- Salts can be built by gluing variable strings together without a delimiter.