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
- If you recently rotated `SECRET_KEY`, add the previous value(s) to `SECRET_KEY_FALLBACKS` so old tokens still verify.
- Confirm the `salt` passed to `dumps`/`loads` (or the `Signer`) is identical on both sides; the default salt is `'django.core.signing'`.
- Treat the raise as expected for untrusted input: catch `BadSignature` (and its `SignatureExpired` subclass) and return a graceful 'invalid/expired' response.
- 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 rotating SECRET_KEY, keep prior values in SECRET_KEY_FALLBACKS until tokens expire.
- Use identical salt values for dumps and loads (default is 'django.core.signing').
- Always handle both BadSignature and SignatureExpired for signed, time-stamped tokens.
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
- No "%s" found in value
- Signature age %s > %s seconds
- The SECRET_KEY setting must not be empty.
- The field %s cannot be referenced.
- The field %s cannot be referenced.
AI-assisted analysis of django/django@b5388a3a80 (2026-08-10).
Data as JSON: /api/errors/6cb82e69344efd7b.
Report an issue: GitHub.