django/django · error · BadSignature

Signature "%s" does not match

Error message

Signature "%s" does not match

What it means

`Signer.unsign()` (signing.py:250-254) raises `BadSignature('Signature "%s" does not match' % sig)` after splitting the value on the separator and finding that the supplied signature fails constant-time comparison against the recomputed HMAC for every candidate key (`self.key` plus each `fallback_keys`). This is the canonical tamper-detection signal for Django's signed tokens.

Source

Thrown at django/core/signing.py:254

                "Unsafe Signer separator: %r (cannot be empty or consist of "
                "only A-z0-9-_=)" % sep,
            )

    def signature(self, value, key=None):
        key = key or self.key
        return base64_hmac(self.salt + "signer", value, key, algorithm=self.algorithm)

    def sign(self, value):
        return "%s%s%s" % (value, self.sep, self.signature(value))

    def unsign(self, signed_value):
        if self.sep not in signed_value:
            raise BadSignature('No "%s" found in value' % self.sep)
        value, sig = signed_value.rsplit(self.sep, 1)
        for key in [self.key, *self.fallback_keys]:
            if constant_time_compare(sig, self.signature(value, key)):
                return value
        raise BadSignature('Signature "%s" does not match' % sig)

    def sign_object(self, obj, serializer=JSONSerializer, compress=False):
        """
        Return URL-safe, hmac signed base64 compressed JSON string.

        If compress is True (not the default), check if compressing using zlib
        can save some space. Prepend a '.' to signify compression. This is
        included in the signature, to protect against zip bombs.

        The serializer is expected to return a bytestring.
        """
        data = serializer().dumps(obj)
        # Flag for if it's been compressed or not.
        is_compressed = False

        if compress:
            # Avoid zlib dependency unless compress is being used.
            compressed = zlib.compress(data)

View on GitHub (pinned to b5388a3a80)

Solutions

  1. If you recently rotated `SECRET_KEY`, add the previous value(s) to `SECRET_KEY_FALLBACKS` so old tokens still verify.
  2. Confirm the `salt` passed to `dumps`/`loads` (or the `Signer`) is identical on both sides; the default salt is `'django.core.signing'`.
  3. Treat the raise as expected for untrusted input: catch `BadSignature` (and its `SignatureExpired` subclass) and return a graceful 'invalid/expired' response.
  4. Regenerate the token from a trusted source if legitimate data failed due to tampering or copy errors.

Example fix

// before
try:
    data = signing.loads(token)
except signing.BadSignature:
    return HttpResponse('Bad token', status=400)  # happens at signing.py:254
// after (support key rotation)
try:
    data = signing.loads(token, max_age=3600)
except signing.SignatureExpired:
    return HttpResponse('expired', status=400)
except signing.BadSignature:
    return HttpResponse('invalid signature', status=400)
Defensive patterns

Strategy: try-catch

Validate before calling

from django.conf import settings
from django.core.signing import Signer

def can_verify_with_current_keys(token: str) -> bool:
    signer = Signer()
    for key in [settings.SECRET_KEY, *settings.SECRET_KEY_FALLBACKS]:
        try:
            signer.unsign(token)
            return True
        except Exception:
            continue
    return False

Try / catch

from django.core import signing
try:
    data = signing.loads(token, max_age=3600)
except signing.SignatureExpired:
    return 'expired', None
except signing.BadSignature:
    return 'invalid', None
else:
    return 'ok', data

Prevention

When it happens

Trigger: Calling `signer.unsign(token)` or `signing.loads(token)` where the value portion was altered after signing (so the appended HMAC no longer matches), or the token was signed with a different `SECRET_KEY`/salt than the verifier uses. The loop at lines 251-252 tries the primary key and all `fallback_keys`; if none match, it raises.

Common situations: User tampering with a signed cookie or activation link; rotating `SECRET_KEY` (or changing the `salt`) without keeping the old key in `SECRET_KEY_FALLBACKS`; copying a token between environments with different secrets; clock/key drift causing `TimestampSigner` to recompute a different HMAC.

Related errors


AI-assisted analysis of django/django@b5388a3a80 (2026-08-10). Data as JSON: /api/errors/6cb82e69344efd7b. Report an issue: GitHub.