• Aug 7, 2026
  • 6 min read

Double Opt-In on Cloudflare Workers with Stateless Signed Links

My subscribe boxes were single opt-in: whoever typed an address into a form became a subscriber. That’s weak consent, and it lets anyone sign up someone else. I wanted double opt-in — subscribe, receive an email, click to confirm — plus an unsubscribe link that works forever.

I decided against a newsletter manager and built it into the forms Worker that already receives every subscription (how that Worker is set up). Confirmations should go out within seconds and keep working when my homelab is down, so the edge is the right place.

The flow

visitor subscribes ──► Worker: lead stored with status "pending"
                         └─ confirmation email sent (waitUntil)
confirm link ────────► /api/forms/confirm?t=…      → status "confirmed"
unsubscribe link ────► /api/forms/unsubscribe?t=…  → suppression entry + status "unsubscribed"

The submit response tells the page what to show: {"ok": true, "next": "confirm_email"}, so the form says “check your inbox” instead of “subscribed”. Without JavaScript, the Worker redirects back with ?form=confirm.

Stateless signed tokens

A classic implementation stores a random token per link in a table. I didn’t want a token table, so the link carries its own data plus a signature:

base64url(JSON payload) + "." + base64url(HMAC-SHA256(secret, payload part))
export interface TokenPayload {
  a: "confirm" | "unsub"; // action
  s: string;              // site id
  e: string;              // normalised email
  k?: string;             // KV key of the lead, when known
  t: number;              // issued at, seconds
}

export async function signToken(secret: string, payload: TokenPayload): Promise<string> {
  const body = toBase64Url(encoder.encode(JSON.stringify(payload)));
  return `${body}.${toBase64Url(await hmac(secret, body))}`;
}

Verification recomputes the HMAC with Web Crypto, compares in constant time, then checks the action and the age:

RuleWhy
Wrong signature → rejectedNobody can forge a link for another address
confirm token used on /unsubscribe → rejectedEach link does one thing
Confirm links expire after 7 daysStale confirmations are suspicious
Unsubscribe links never expireAn email from two years ago must still let you leave

The confirm token includes the lead’s KV key, so confirming is one get and one put. The secret is generated once, never printed, and piped into the Worker:

(umask 077; openssl rand -base64 48 | tr -d '\n' > ~/.config/leads-hub/token_secret)
npx wrangler secret put TOKEN_SECRET < ~/.config/leads-hub/token_secret

The homelab keeps a copy of the same file, because drip emails sent from there need unsubscribe links the Worker accepts.

This is the part I’d have got wrong without reading about it first. Corporate mail filters and services like Outlook’s Safe Links open every URL in an email to check it. If opening the confirm link confirmed, robots would confirm subscriptions nobody asked for. If opening the unsubscribe link unsubscribed, a filter could silently unsubscribe a reader.

So GET only shows a small page with a button, and the button’s POST does the action:

if (request.method === "GET") return actionPage(site, action, token);
// POST: confirm, or write the suppression entry

The page is branded per site, says what will happen, and posts the token back in a hidden field. Nothing changes until a human presses the button.

One-click unsubscribe is the exception

Gmail and Yahoo require bulk senders to support RFC 8058 one-click unsubscribe. Every marketing email carries:

List-Unsubscribe: <https://example.com/api/forms/unsubscribe?t=…>
List-Unsubscribe-Post: List-Unsubscribe=One-Click

When the reader clicks the mail client’s own “Unsubscribe” button, the provider sends a POST with the body List-Unsubscribe=One-Click to that URL, token in the query string. That request acts immediately, as the RFC requires, and gets a plain 200:

oneClick = data?.["List-Unsubscribe"] === "One-Click";
// ...
if (oneClick) return new Response("Unsubscribed", { status: 200 });

Unsubscribing writes an entry to a shared suppression list keyed by a hash of the address — more on that in the next post.

Sending the confirmation

The Worker calls Mailtrap’s transactional API with fetch, from a no-reply address on a verified sending domain, with Reply-To pointing at a real mailbox. It runs in waitUntil, after the lead is stored, so the visitor’s response never waits on the email provider:

if (doubleOptIn) {
  ctx.waitUntil(sendConfirmation(env, site, lead, key).catch((e) => console.error("confirmation email", e)));
  return json({ ok: true, next: "confirm_email" }, 202);
}

If the provider refuses the email, I get an ntfy alert with the status code and the email domain — never the address. In my live test the email arrived within seconds of submitting the form.

Testing on production without touching visitors

I didn’t want a staging copy of three sites. Instead, double opt-in is gated by two settings:

export function needsDoubleOptIn(env: Env, lead: Lead): boolean {
  if (!env.TOKEN_SECRET) return false;
  if (lead.kind !== "newsletter" && !lead.consent?.marketing) return false;
  const sites = list(env.DOUBLE_OPT_IN_SITES);
  return sites.includes("*") || sites.includes(lead.site) || list(env.DOI_TEST_EMAILS).includes(lead.email);
}
  • DOUBLE_OPT_IN_SITES (a plain variable, empty at first) turns it on per site, or "*" for all.
  • DOI_TEST_EMAILS (a secret, so my address isn’t in git) turns it on for listed addresses only.

With my own address in DOI_TEST_EMAILS, I subscribed on the live site and went through the real flow — email, button page, confirmed status, then the unsubscribe link — while every other visitor kept the old behaviour. Without TOKEN_SECRET, double opt-in is off entirely, which is also the rollback.

A test bug worth remembering

The Worker’s tests run on node --test with fake KV and a fake fetch. One test failed with “subscription not found” although the code was right. The cause: an earlier test didn’t await its waitUntil promises, and its late confirmation-email call landed in the next test’s fetch mock — so the next test read the wrong confirmation link.

The fix was in the harness, not the Worker: drain the previous test’s background promises before resetting the mocks.

beforeEach(async () => {
  // Drain the previous test's waitUntil work so it can't leak into this one.
  await Promise.allSettled(pending ?? []);
  store = new Map();
  // ...
});

Takeaways

  • Stateless HMAC tokens remove the token table: the link carries site, email, action and issue time, and the signature protects them.
  • Confirm links expire; unsubscribe links never do.
  • GET shows a button, POST acts — mail scanners prefetch links. RFC 8058 one-click POST is the only request that acts directly.
  • Send the confirmation in waitUntil, after storing, and alert on failure without the address.
  • Gate new behaviour by recipient (DOI_TEST_EMAILS) to test the real thing on production.

Earlier: retention as a KV TTL and self-hosting ntfy. Next: a suppression list that stores no email addresses.