Shipping fast with AI but don't fully trust the code? I help developers 1:1 turn AI-built apps into something they understand and own. How it works →
Django has a signing module that makes it easy to build an unsubscribe link that works with no login and no session: token = signing.dumps(recipient.pk, salt=UNSUBSCRIBE_SALT). The token itself is the credential, and it ships with Django out of the box.
The URL is the whole request #
Unsubscribing poses a bit of a pickle. A user can click the link in an email without being logged in, and you don't want to force them to log in just to stop receiving emails. So the URL must carry both who they are and what they're allowed to do, all on its own.
The tempting but insecure way to do this:
def unsubscribe(request):
user_id = request.GET["user_id"]
Profile.objects.filter(user_id=user_id).update(opt_out=True)
/unsubscribe/?user_id=42 works, and it also lets anyone loop from 1 upward and unsubscribe your entire user table. It's a plain insecure direct object reference: the URL names a record and nothing proves the clicker is allowed to touch it.
The usual fix is a random token stored in the database, looked up on click. That's fine, but it means a column or a table, a migration, and code to create and expire the tokens. For a boolean flag on a profile, that's a lot of moving parts.
Django already fixes this #
django.core.signing gives you a tamper-evident token with no storage at all. It packs your value and a signature made from SECRET_KEY:
from django.core import signing
UNSUBSCRIBE_SALT = "discuss-unsubscribe"
token = signing.dumps(recipient.pk, salt=UNSUBSCRIBE_SALT)
url = f"{settings.DOMAIN}{reverse('discuss_unsubscribe', args=[token])}"
On the way back in, loads verifies the signature and hands you the original value. In this case unsubscribing a user from forum post notifications:
def unsubscribe(request, token):
try:
user_pk = signing.loads(token, salt=UNSUBSCRIBE_SALT)
profile = Profile.objects.get(user_id=user_pk)
profile.opt_out_discussion_emails = True
profile.save(update_fields=["opt_out_discussion_emails"])
success = True
except (signing.BadSignature, Profile.DoesNotExist):
success = False
return render(request, "unsubscribe.html", {"success": success})
Change one character of the token and the signature no longer matches, so loads raises BadSignature and the pk never gets used. Guessing another user's pk is pointless: you can't produce a valid signature for it without SECRET_KEY. No table, no lookup, no cleanup.
If you've ever clicked a thumbs-up in a GitHub Copilot feedback email and seen a URL that's a wall of base64, that's the same idea: a signed token identifying you and your action, no login in the loop.
How to prevent multiple flows from colliding #
A salt argument scopes a token to one purpose. This codebase now has two separate unsubscribe flows, and they use different salts:
UNSUBSCRIBE_SALT = "announce-unsubscribe"
UNSUBSCRIBE_SALT = "discuss-unsubscribe"
Both sign a user pk with the same SECRET_KEY. But a token minted for the announcements list will not validate in the forum-reply handler, because the salts differ.
Without that, a single leaked token would work against every flow that signs a pk. The salt keeps a token minted for one purpose from being accepted by another flow. Name it per action and you get that separation for free.
Signed is not secret #
One honest point the convenience can hide: signing proves a value wasn't altered, not that it's hidden. The pk is sitting in that token, base64-encoded and readable by anyone who pastes it into a decoder.
That's fine for a user id, which isn't a secret. It is not fine for anything you'd mind a recipient reading. Never sign a value you wouldn't also be willing to print in the email body.
Don't mutate on a GET #
There's a subtlety the happy path hides: my handler flips a flag on a plain GET request. Mail security scanners and link-prefetchers routinely follow links inside email before a human ever clicks, which would unsubscribe people who never asked to be. The signing is fine, the HTTP method is the risk.
For unsubscribe the fix is cheap: render a confirmation page on GET and do the write on the POST. The signed token still carries the identity, the button carries the intent.
Mailbox providers formalize the same POST-not-GET rule: RFC 8058 has providers like Gmail and Apple Mail POST straight to the URL in your List-Unsubscribe header (paired with List-Unsubscribe-Post: List-Unsubscribe=One-Click), unsubscribing in one click. That endpoint has to be CSRF-exempt, since the provider sends no token.
When you need expiry #
My unsubscribe tokens never expire, and that's a deliberate design choice.
Unsubscribe is low-stakes: a two-year-old link still does the one safe thing, even if the mail gets forwarded and a stranger clicks it. The stakes are what decide this.
Reuse the same pattern for a magic login link or an email verification. Here you do want a lifetime, so set a max_age on the loads call:
user_pk = signing.loads(token, salt="magic-login", max_age=900)
max_age gives you expiry with still no storage: the deadline is baked into the signed token, so the signature verifies it without a database.
Single-use is different, it needs memory. The signature can't tell you a token was already spent, so you'd have to record each accepted token and check that list. That's the state you're forced to add.
An unsubscribe link is idempotent, clicking it twice does the same safe thing, so it can stay stateless. A login link can't: reusing it opens the account again.
Keep reading #
- How to Migrate Users Seamlessly Between Django Apps on Login
- How to Update Multiple Page Elements from One htmx Request
Where else in your app does a URL quietly get trusted? What other pickles have you had to deal with in your Django codebases?