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

Security: newsletter unsubscribe requires a signed token

Anyone could remove someone else from your newsletter knowing only their email. Gmail's one-click unsubscribe works again too, and the Claude format hook stops reformatting the whole repo.

The second security release of the day. It closes a smaller hole than 2.1.2, but one worth applying: nobody could read data, yet anyone could remove subscribers from your newsletter.

Unsubscribing requires a token

POST /api/newsletter/unsubscribe unsubscribed any email it received, without checking anything. Knowing someone's email was enough to take them off your list.

It now requires a token signed with BETTER_AUTH_SECRET, the same one the unsubscribe page links already carried. Without a token it answers 400, with an invalid token or one for another email 403, and with a valid one it always gives the same answer, so it does not reveal who is subscribed. If the secret is missing, every unsubscribe is rejected instead of signing with an empty key.

Gmail's unsubscribe button works

The List-Unsubscribe headers pointed to the endpoint without an email or a token, and the Origin protection rejected the one-click request mail providers send, which carries no such header. Gmail's "Unsubscribe" button never worked. The headers now carry the email and the token, and the endpoint skips the Origin check: the token is the proof.

The Claude format hook

The hook that formats every file Claude edits read a variable Claude Code never sets. With an empty path, Prettier reformatted the whole repo on every edit, and ESLint errors were lost. It now formats only the edited file and sends back to Claude the errors that do not fix themselves, so they get fixed in the same turn.

If you already have a project

  1. Copy src/lib/email/unsubscribe-links.ts and use buildUnsubscribeLinks() in src/app/api/newsletter/subscribe/route.ts for the headers and the email link.
  2. In src/app/api/newsletter/unsubscribe/route.ts, read email and token from the query or the body (one-click arrives as a form, not JSON). If either is missing, 400. If verifyUnsubscribeToken fails, 403.
  3. Add if (!getSecret()) return false; at the top of verifyUnsubscribeToken().
  4. Add '/api/newsletter/unsubscribe' to ORIGIN_VALIDATION_BYPASS_PREFIXES in src/proxy.ts.
  5. Check your own emails with grep -rn "unsubscribe" src/lib/email src/app/api: an unsubscribe link without a token stops working, and a subscriber who cannot unsubscribe is a legal problem.
  6. For the hook, copy .claude/hooks/format-lint.ts and the PostToolUse block of .claude/settings.json.

No migrations and no new environment variables. Note: rotating BETTER_AUTH_SECRET invalidates the unsubscribe links already sitting in inboxes.