skip to content

In Django's django.core.signing, what does the salt argument do, and why is reusing one salt across features dangerous?

level: middleimportance: should knowfreq 35%

answer

  1. a namespace, not a secret
  2. mixed into the HMAC key
  3. default salts per call
  4. cross-feature token replay

basics

~20 s

The 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 s

In `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 lines
python
from 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

for a junior

Recall that the same salt must be passed when signing and when verifying, and that a mismatch raises BadSignature.

for a middle

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.

for a senior

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.

for a principal

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.