# Unsubscribe links without a login: Django signing

> Source: <https://belderbos.dev/blog/unsubscribe-without-login-django-signing/>
> Published: 2026-08-22 00:00:00+00:00

# Unsubscribe links without a login: Django signing

*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)*

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:

``` python
# don't 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](https://owasp.org/www-community/attacks/): 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`:

``` python
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:

``` python
def unsubscribe(request, token):
    try:
        user_pk = signing.loads(token, salt=UNSUBSCRIBE_SALT)
        profile = Profile.objects.get(user_id=user_pk)
        # writes on GET for now; see "Don't mutate on a GET" before shipping
        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:

```
# bites/management/commands/send_announcement.py
UNSUBSCRIBE_SALT = "announce-unsubscribe"

# discuss/notify.py
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:

```
# magic login: valid for 15 minutes (single-use needs the state below)
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](/blog/login-triggered-user-migration-django/)
- [How to Update Multiple Page Elements from One htmx Request](/blog/htmx-hx-swap-oob-django/)

Where else in your app does a URL quietly get trusted? What other pickles have you had to deal with in your Django codebases?
