skip to content

In Django's django.core.signing, what does Signer.sign() guarantee about a value, and why can anyone still read the signed result?

level: juniorimportance: must knowfreq 45%

answer

  1. integrity versus confidentiality
  2. what the colon separates
  3. HMAC keyed from SECRET_KEY
  4. unsign() and BadSignature

basics

~10 s

Signer.sign() appends an HMAC-SHA256 signature derived from SECRET_KEY, so unsign() detects any change and raises BadSignature. It does not encrypt: the value, or its base64 JSON, travels in the clear.

solid answer

~30 s

`django.core.signing.Signer` gives integrity and authenticity, not confidentiality. `sign("42")` returns `42:<signature>`: the value, the `:` separator and a URL-safe base64 HMAC (SHA-256 by default) keyed from `SECRET_KEY` and the signer's salt. `unsign()` recomputes the HMAC, compares it in constant time against `SECRET_KEY` and then each entry of `SECRET_KEY_FALLBACKS`, and returns the value or raises `BadSignature`. `sign_object()` and `signing.dumps()` JSON-encode and base64 the payload first, which is encoding, not encryption, so anyone holding the token can decode it. So I sign identifiers, never secrets or personal data, and I treat `BadSignature` as a rejection, never as a warning to log and ignore.

code

python · 11 lines
python
from django.core.signing import BadSignature, Signer

signer = Signer()
token = signer.sign("42")        # '42:<signature>'
assert signer.unsign(token) == "42"

forged = "43" + token[2:]        # same signature, different value
try:
    signer.unsign(forged)
except BadSignature:
    print("rejected: value was changed")

go deeper

for a junior

Recall that sign() appends a signature after a colon, unsign() returns the value or raises BadSignature, and that the value itself stays readable.

for a middle

Explain the HMAC keyed from SECRET_KEY and the salt, why sign_object() and dumps() are base64 JSON rather than ciphertext, and how default salts keep Signer and dumps() tokens apart.

for a senior

Show judgment about payload contents: sign identifiers only, re-check authorization at use time, and never degrade BadSignature into a logged warning in production code.

for a principal

Frame signing as a stateless integrity tool: weigh it against server-side state when data must be hidden or tokens must be revocable one by one.

## What signing is, and what it is not **Signing** attaches a short cryptographic proof to a value so that whoever receives it back can check two things: the value has not been changed, and it was produced by someone holding the secret key. In Django this lives in `django.core.signing`. It sits underneath signed cookies, the `signed_cookies` session engine and many hand-rolled links such as email confirmations and unsubscribe URLs. What signing does **not** provide is **confidentiality**. The value is not encrypted; it travels next to its signature in a form anyone can read. Confusing the two is the most common mistake candidates make about this module. ## The anatomy of a signed string `Signer` is the basic class. Its constructor takes keyword-only arguments (`key`, `sep`, `salt`, `algorithm`, `fallback_keys`); passing them positionally was removed in Django 5.1. ```python >>> from django.core.signing import Signer >>> signer = Signer() >>> signer.sign("42") '42:<url-safe base64 signature>' ``` The output has three parts: - the **original value**, unchanged (`42`); - the **separator**, `:` by default; Django raises `ValueError` for a separator that is empty or made only of URL-safe base64 characters; - the **signature**: an HMAC of the value, SHA-256 by default, computed with a key derived from `SECRET_KEY` and the signer's **salt**, then URL-safe base64 encoded without padding. Because the key is derived from `SECRET_KEY`, only code holding that setting can produce a signature that verifies. The salt is not secret; it only separates one use of signing from another. ## Verifying: unsign() and BadSignature `unsign()` splits the string on the last separator, recomputes the HMAC over the value part and compares signatures with `constant_time_compare`, so response timing does not reveal how many leading bytes of a guessed signature were right. Then: 1. if the signature matches the one computed with `SECRET_KEY`, or with any entry in `SECRET_KEY_FALLBACKS` (tried in list order), it returns the original value; 2. otherwise, or when the separator is missing, it raises `django.core.signing.BadSignature`. There is no partial success. Changing one character of the value or of the signature makes verification fail, and the right response is to reject the input: a 400 or 404, an invalid-link page, or treating a cookie as absent. Catching `BadSignature` and carrying on with the unverified text throws away the only guarantee the module gives. ## Why the payload stays readable For strings the readability is obvious: `42` sits in plain sight. For structured data, `Signer.sign_object()` and the shortcut `signing.dumps()` first serialise the object to compact JSON and then URL-safe base64 encode it. **Base64 is an encoding, not encryption**: reversing it needs no key. Anyone who copies the token from a URL, a cookie or a log line can split off the signature and decode the JSON. ```python import base64, json from django.core import signing token = signing.dumps({"user_id": 42}) payload = token.split(":")[0] json.loads(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4))) # {'user_id': 42} ``` The optional `compress=True` flag does not change this. It zlib-compresses the JSON when that makes it shorter and marks the payload with a leading `.`; decompressing still needs no key. ## Choosing the right call | Call | Input | Adds a timestamp | Default salt | |---|---|---|---| | `Signer().sign()` | a string | no | `django.core.signing.Signer` | | `Signer().sign_object()` | a JSON-serialisable object | no | `django.core.signing.Signer` | | `TimestampSigner().sign()` | a string | yes | `django.core.signing.TimestampSigner` | | `signing.dumps()` | a JSON-serialisable object | yes | `django.core.signing` | Because the default salts differ, a value signed by one row does not verify under another row's defaults: `signing.loads()` rejects a token from `Signer().sign_object()` with `BadSignature`. ## What belongs in a signed value - **Identifiers and small flags** are fine: a primary key, a list name, a step number. - **Secrets and personal data** are not: an email address, a one-time code, an internal price. Assume every signed value will be read. - **Authorization still happens at use time**: a signed `user_id` proves Django issued it, not that the user still exists or still has access. - **JSON only**: the default `JSONSerializer` turns tuples into lists and refuses arbitrary Python objects. That is deliberate: even with a stolen `SECRET_KEY`, an attacker cannot turn a forged token into code execution the way a pickle-based format would allow. If the data itself must stay hidden, keep it on the server and sign only an opaque reference to it; `django.core.signing` has no encryption mode.

  • Can signing.loads() read a token produced by Signer().sign_object()?
    No. `signing.loads()` builds a `TimestampSigner` with salt `django.core.signing`, while `Signer()` defaults to salt `django.core.signing.Signer` and adds no timestamp. The salts feed the HMAC key, so the signature does not match and `loads()` raises `BadSignature`. Pair `sign_object()` with `unsign_object()` on the same signer, or `dumps()` with `loads()`.
  • Why does unsign() compare signatures with constant_time_compare rather than ==?
    A plain string comparison can return as soon as the first differing character is found, so its duration leaks how much of a guessed signature was right. With enough requests an attacker could build a valid signature byte by byte. `constant_time_compare` takes the same time whatever the inputs, closing that timing side channel.
  • The product team wants the user's email inside a signed link; what do you tell them?
    The payload is only base64 JSON, so the email would be readable by anyone who sees the link: mail relays, browser history, analytics, logs. Sign the primary key instead and look the email up on the server. `django.core.signing` offers no encryption, so hiding data means not putting it in the token.

A signed value is a postcard with a wax seal across the text: nobody can alter the words without breaking the seal, but the postman can read every word.

saying these in an interview costs you the question

  • Signing encrypts the value, so users cannot read what is inside.
  • Base64 in the token means the payload is hashed and cannot be reversed.
  • After a BadSignature it is fine to log a warning and use the value anyway.
  • Anyone who knows the salt can forge a valid signature.
  • Signed tokens must be stored in a database table so they can be verified.