skip to content

Using Django's django.core.signing, how would you build an unsubscribe link that expires after 30 days, and handle stale or forged tokens?

level: middleimportance: must knowfreq 50%

answer

  1. timestamp signed into the token
  2. expiry checked on the read side
  3. a per-feature salt
  4. exception subclass order

basics

~10 s

Sign the subscriber id with signing.dumps(..., salt=...), which embeds a signed timestamp. The view calls signing.loads(token, salt=..., max_age=timedelta(days=30)), catching SignatureExpired before BadSignature.

solid answer

~40 s

I put the smallest stable identifier in the token, `signing.dumps({"sid": subscriber.pk}, salt="newsletter.unsubscribe")`, and route it with `path("unsubscribe/<str:token>/", ...)`. `dumps()` uses a `TimestampSigner`, so the signing time is part of the signed data. Expiry is enforced only when reading: `signing.loads(token, salt=..., max_age=timedelta(days=30))` raises `SignatureExpired` for an old but genuine token and `BadSignature` for anything tampered or signed with another salt or key. `SignatureExpired` subclasses `BadSignature`, so its `except` comes first; I show an expired-link page for it and a plain 400 for the rest. Without `max_age`, `loads()` never checks age at all. Because mail scanners may follow links, GET only confirms and POST performs the unsubscribe.

code

python · 29 lines
python
from datetime import timedelta

from django.core import signing
from django.http import HttpResponseBadRequest
from django.shortcuts import render
from django.urls import reverse

from newsletter.models import Subscriber

UNSUBSCRIBE_SALT = "newsletter.unsubscribe"
UNSUBSCRIBE_MAX_AGE = timedelta(days=30)


def unsubscribe_url(request, subscriber):
    token = signing.dumps({"sid": subscriber.pk}, salt=UNSUBSCRIBE_SALT)
    return request.build_absolute_uri(reverse("newsletter:unsubscribe", args=[token]))


def unsubscribe(request, token):
    try:
        data = signing.loads(token, salt=UNSUBSCRIBE_SALT, max_age=UNSUBSCRIBE_MAX_AGE)
    except signing.SignatureExpired:
        return render(request, "newsletter/link_expired.html", status=410)
    except signing.BadSignature:
        return HttpResponseBadRequest("Invalid unsubscribe link.")
    if request.method == "POST":
        Subscriber.objects.filter(pk=data["sid"]).update(active=False)
        return render(request, "newsletter/unsubscribed.html")
    return render(request, "newsletter/confirm_unsubscribe.html", {"token": token})

go deeper

for a junior

Recall the pair: dumps() with a salt to build the token, loads() with the same salt and max_age to read it, and which two exceptions can come back.

for a middle

Explain that the timestamp is signed at dumps() time and expiry is enforced only by loads(max_age=...), and why SignatureExpired must be caught before BadSignature.

for a senior

Cover the production edges: link-prefetching scanners, replay inside the window, deleted subscribers, and keeping rotated keys in the fallbacks for the full max_age.

for a principal

Weigh stateless signed links against stored random tokens: issue cost and cleanup versus per-token revocation and single use.

## The job A newsletter email carries a link that lets the reader unsubscribe without logging in. The link must identify the subscriber, must be impossible to forge (nobody should unsubscribe someone else by editing an id), and should stop working after a while so that old emails sitting in archives do not stay live forever. `django.core.signing` does all three without a database table. ## Building the token `signing.dumps(obj, salt=...)` is a shortcut for `TimestampSigner(salt=...).sign_object(obj)`. It: 1. serialises the object to compact JSON with the default `JSONSerializer`; 2. URL-safe base64 encodes it (zlib-compressed first if you pass `compress=True` and that helps); 3. appends the current Unix time in base62, then an HMAC signature keyed from `SECRET_KEY` and the salt. The result looks like `payload:timestamp:signature` and uses only URL-safe characters plus `:`, so it fits a path segment captured by `<str:token>`, whose pattern matches anything except `/`. The timestamp is inside the signed part, so nobody can extend a link by editing it. Put the **smallest stable identifier** in the payload, the subscriber's primary key and perhaps the list name, not the email address: the payload is readable by anyone who sees the link. Give the feature its **own salt**, such as `newsletter.unsubscribe`. The default salt of `dumps()` is `django.core.signing`, shared by every other call that forgets to pass one, so without a dedicated salt a token minted for another feature with a compatible payload could be replayed here. ## Checking expiry: max_age lives on the read side The expiry is **not** stored in the token. `dumps()` records when the token was signed; `loads()` decides how old is too old: - `signing.loads(token, salt=..., max_age=...)` verifies the signature, then compares the embedded timestamp with the current time; - `max_age` accepts seconds as an integer or a `datetime.timedelta`; - a token older than `max_age` raises `SignatureExpired`; - if `max_age` is omitted (the default is `None`), **no age check happens at all**, and the token stays valid for as long as the key that signed it is accepted. That last point is the classic bug: the developer sees a timestamp inside the token and assumes the link expires. It does not, unless every `loads()` call passes `max_age`. Keep the window in one constant that the view uses. A side effect of read-side expiry is that changing the constant immediately shortens or lengthens the life of tokens already sent. ## Handling the failures `SignatureExpired` is a **subclass** of `BadSignature`, so the order of the `except` clauses matters: | Exception | Meaning | Sensible response | |---|---|---| | `SignatureExpired` | genuine token, older than `max_age` | explain the link expired and offer a way to manage preferences | | `BadSignature` | tampered, truncated, wrong salt, or signed by a key no longer accepted | treat as invalid: a 400 or 404 with no detail | If `except BadSignature` comes first, it also catches expiry, and users with old emails get a confusing invalid-link page. ## Design points interviewers probe - **Replay within the window is allowed.** A signed token is stateless: it verifies every time until it expires. That is harmless for an idempotent action like unsubscribing; a one-time action needs server-side state as well. - **Mail scanners follow links.** Some mail security tools open every URL in incoming messages. If a GET performs the unsubscribe, a scanner can unsubscribe people. The usual pattern is: GET verifies the token and shows a confirmation form; POST, carrying the same token, makes the change. - **Key rotation interacts with lifetime.** Tokens verify against `SECRET_KEY` and then `SECRET_KEY_FALLBACKS`; after a rotation the old key must stay in the fallbacks at least as long as `max_age`, or live links break. - **The row may be gone.** A valid signature proves Django issued the token, not that the subscriber still exists, so use a query that tolerates absence, such as `filter(...).update(...)`. ## Why not a database token? A random token stored in a table can be revoked individually and consumed exactly once, at the cost of a write per email and a cleanup job. A signed token costs nothing to issue and needs no cleanup, but it can only be revoked in bulk, by rotating the key or shrinking `max_age`. For unsubscribe links, the signed token is the usual choice.

  • Product later wants links to last 60 days instead of 30. What happens to links already sent?
    They gain 30 days immediately. The token stores only the signing time; `max_age` is applied by `loads()` at read time, so raising the constant extends every outstanding link and lowering it expires them early. No re-issue is needed in either direction.
  • How would you make a signed link usable only once?
    Signing alone cannot, because verification is stateless. Add server state: record a used flag or a version counter on the row, include that value in the signed payload, and reject the token when the stored value has moved on. For unsubscribe this is unnecessary because the action is idempotent.
  • Where does the 30-day window actually live, and what if a developer copies the loads() call without it?
    Only in the `max_age` argument at the call site. A copy that drops it verifies the signature but skips the age check, so tokens from years ago keep working. A shared constant, or a small helper that always passes it, stops that drift.

saying these in an interview costs you the question

  • signing.dumps() writes an expiry date into the token, so loads() rejects old tokens automatically.
  • except BadSignature can go first; SignatureExpired is a separate exception family.
  • Putting the subscriber's email in the payload is safe because the token is signed.
  • Performing the unsubscribe on GET is fine because the token is signed.
  • Every issued token must be saved in a table so the view can look it up.