{"slug": "unsubscribe-links-without-a-login-django-signing", "title": "Unsubscribe links without a login: Django signing", "summary": "Django's built-in signing module enables secure, login-free unsubscribe links by embedding a tamper-evident token in the URL, eliminating the need for database-stored tokens. The token, created with signing.dumps(recipient.pk, salt=UNSUBSCRIBE_SALT) and verified with signing.loads, uses the SECRET_KEY to prevent forgery, and a salt argument scopes tokens to specific flows to prevent cross-use. The approach avoids insecure direct object references and reduces database overhead, as demonstrated in a code example for opting out of forum notifications.", "body_md": "# Unsubscribe links without a login: Django signing\n\n*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 →](/coaching/#own-project)*\n\nDjango 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.\n\n## The URL is the whole request\n\nUnsubscribing 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.\n\nThe tempting but insecure way to do this:\n\n``` python\n# don't do this\ndef unsubscribe(request):\n    user_id = request.GET[\"user_id\"]\n    Profile.objects.filter(user_id=user_id).update(opt_out=True)\n```\n\n`/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](https://owasp.org/www-community/attacks/): the URL names a record and nothing proves the clicker is allowed to touch it.\n\nThe 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.\n\n## Django already fixes this\n\n`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`:\n\n``` python\nfrom django.core import signing\n\nUNSUBSCRIBE_SALT = \"discuss-unsubscribe\"\ntoken = signing.dumps(recipient.pk, salt=UNSUBSCRIBE_SALT)\nurl = f\"{settings.DOMAIN}{reverse('discuss_unsubscribe', args=[token])}\"\n```\n\nOn the way back in, `loads` verifies the signature and hands you the original value. In this case unsubscribing a user from forum post notifications:\n\n``` python\ndef unsubscribe(request, token):\n    try:\n        user_pk = signing.loads(token, salt=UNSUBSCRIBE_SALT)\n        profile = Profile.objects.get(user_id=user_pk)\n        # writes on GET for now; see \"Don't mutate on a GET\" before shipping\n        profile.opt_out_discussion_emails = True\n        profile.save(update_fields=[\"opt_out_discussion_emails\"])\n        success = True\n    except (signing.BadSignature, Profile.DoesNotExist):\n        success = False\n    return render(request, \"unsubscribe.html\", {\"success\": success})\n```\n\nChange 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.\n\nIf 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.\n\n## How to prevent multiple flows from colliding\n\nA `salt` argument scopes a token to one purpose. This codebase now has two separate unsubscribe flows, and they use different salts:\n\n```\n# bites/management/commands/send_announcement.py\nUNSUBSCRIBE_SALT = \"announce-unsubscribe\"\n\n# discuss/notify.py\nUNSUBSCRIBE_SALT = \"discuss-unsubscribe\"\n```\n\nBoth 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.\n\nWithout 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.\n\n## Signed is not secret\n\nOne 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.\n\nThat'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.\n\n## Don't mutate on a GET\n\nThere'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.\n\nFor 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.\n\nMailbox 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.\n\n## When you need expiry\n\nMy unsubscribe tokens never expire, and that's a deliberate design choice.\n\nUnsubscribe 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.\n\nReuse 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:\n\n```\n# magic login: valid for 15 minutes (single-use needs the state below)\nuser_pk = signing.loads(token, salt=\"magic-login\", max_age=900)\n```\n\n`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.\n\nSingle-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.\n\nAn 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.\n\n## Keep reading\n\n- [How to Migrate Users Seamlessly Between Django Apps on Login](/blog/login-triggered-user-migration-django/)\n- [How to Update Multiple Page Elements from One htmx Request](/blog/htmx-hx-swap-oob-django/)\n\nWhere else in your app does a URL quietly get trusted? What other pickles have you had to deal with in your Django codebases?", "url": "https://wpnews.pro/news/unsubscribe-links-without-a-login-django-signing", "canonical_source": "https://belderbos.dev/blog/unsubscribe-without-login-django-signing/", "published_at": "2026-08-22 00:00:00+00:00", "updated_at": "2026-09-07 12:57:27.990779+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["Django", "GitHub Copilot"], "alternates": {"html": "https://wpnews.pro/news/unsubscribe-links-without-a-login-django-signing", "markdown": "https://wpnews.pro/news/unsubscribe-links-without-a-login-django-signing.md", "text": "https://wpnews.pro/news/unsubscribe-links-without-a-login-django-signing.txt", "jsonld": "https://wpnews.pro/news/unsubscribe-links-without-a-login-django-signing.jsonld"}}