ADR-0001. Token-based unsubscribe (no auth, no session)
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-05-01 |
| Deciders | outreach-team |
| Supersedes | — |
Context
- Newsletter recipients must be able to unsubscribe from a one-click link in an email, without logging in.
- A subscriber is a public, unauthenticated actor — there is no JWT, no session, no merchant scope.
- We need a way to authorize "deactivate this exact subscription" that is safe to embed in a plaintext email URL and cannot be used to enumerate or deactivate other people's subscriptions.
Decision
Each Subscriber row carries an unsubscribeToken, generated via IdGenerator.nextId() (a 64-bit Snowflake) on insert. The unsubscribe link is GET /subscribers/unsubscribe?token=<unsubscribeToken> and requires no authentication.
SubscriberService.unsubscribe() looks the token up; a hit sets status=DEACTIVATED and stamps unsubscribedAt; a miss throws UNSUBSCRIBE_INVALID_TOKEN (HTTP 404). The token is declared in the model's hiddenProperties, so it is never returned by any read endpoint — the only way to obtain it is to receive the email.
Consequences
| Pros | Cons |
|---|---|
| One-click unsubscribe with zero auth friction | Token is a bearer secret in a plaintext email URL |
| Token never leaks via the API (hidden property) | No expiry / rotation — a leaked link works indefinitely |
| Snowflake space (64-bit) makes guessing infeasible | Re-subscribe issues no new token (token is stable across deactivate/reactivate) |
| Idempotent: re-hitting the link on an already-deactivated row is harmless | 404 on bad token slightly leaks "token does not exist" |
Alternatives Considered
| Option | Pros | Cons | Why rejected |
|---|---|---|---|
| Signed JWT in the link | Built-in expiry, no DB column | Requires key management; long ugly URLs; subscriber has no account to scope to | Overkill for a public list |
| Email + confirmation step | Verifies ownership | Extra friction; defeats one-click expectation | Hurts unsubscribe completion (and legal one-click intent) |
| Random UUID token | Same UX | Needs separate generator; Snowflake already available via IdGenerator | Snowflake reuses existing infra |
References
packages/core/src/models/schemas/outreach/subscriber/schema.ts(unsubscribeToken$defaultFn,hiddenProperties)packages/outreach/src/services/subscriber.service.ts(unsubscribe)packages/outreach/src/errors/subscriber.errors.ts(UNSUBSCRIBE_INVALID_TOKEN)packages/outreach/src/controllers/subscriber/definitions.ts(UNSUBSCRIBEroute)