Skip to main content
Back to Changelog
v2.1.4 · Patch

Security: the unsubscribe page asks for confirmation

Corporate mail link scanners could unsubscribe someone without them ever clicking. The page now asks first, and the unsubscribe endpoint limits failed attempts.

The third security release of the day, tying up the two loose ends 2.1.3 left in the newsletter unsubscribe.

Opening the unsubscribe page no longer unsubscribes

The /[locale]/unsubscribe page unsubscribed the address as soon as it was opened with a valid link. The catch is that a person is not always the one opening it: Microsoft Safe Links, Proofpoint and other corporate mail scanners visit every link in an email before delivering it, and browsers sometimes prefetch links. A subscriber could drop off your list without ever clicking.

Now, with a valid link, the page asks "Unsubscribe from the X newsletter?" and shows a Confirm unsubscribe button. Only that button unsubscribes. An invalid link or a missing token still shows "Invalid link" as before. The texts come in English and Spanish.

Gmail's "Unsubscribe" button and other clients' (RFC 8058 one-click) are unchanged: they still unsubscribe directly, because that is a deliberate request from the mail provider and scanners never make it.

A limit on failed unsubscribe attempts

POST /api/newsletter/unsubscribe only had the general API limit. It now allows 10 failed attempts per hour per IP (no token or an invalid one) and answers 429 from the eleventh on.

An unsubscribe with a valid token never counts, whether it comes from the page, your own call or Gmail's button, and it goes through even from an IP blocked by failures. The token already proves the request is legitimate, and mail providers send one-click unsubscribes from a few shared IPs: a per-IP limit on those requests would end up refusing real unsubscribes.

If you already have a project

  1. Copy src/features/marketing/components/unsubscribe-content.tsx, src/features/marketing/components/unsubscribe-confirm.tsx and src/features/marketing/hooks/use-unsubscribe-confirm.ts, and add the unsubscribe block of src/config/locales/{en,es}/marketing.json to your locale files.
  2. If you customised the unsubscribe page, keep your design but move the call to unsubscribeContact() out of it: the page must only check the token and show the button, which POSTs the email and token to /api/newsletter/unsubscribe.
  3. In src/lib/security/rate-limit/rate-limiter.ts, add a NEWSLETTER_UNSUBSCRIBE entry to RATE_LIMITS with maxRequests: 10 and windowSeconds: 60 * 60.
  4. In src/app/api/newsletter/unsubscribe/route.ts, apply the limit only on the 400 and 403 answers: before returning them, call rateLimitMiddleware(request, RATE_LIMITS.NEWSLETTER_UNSUBSCRIBE, { prefix: 'newsletter-unsubscribe' }) and return its 429 when it is not null. Leave the valid-token path alone.
  5. Rebuild from clean (rm -rf .next && bun run build) before deploying.

No migrations and no new environment variables, and the unsubscribe links already sitting in inboxes keep working: the token and the URLs are the same. With more than one instance, the limit is per instance unless you set REDIS_URL.