Plainwork

Plainwork / Signup Shield Kit / Double opt-in signup for a static site, Cloudflare Worker

Double opt-in signup for a static site, with a free Cloudflare Worker

A waitlist or newsletter form on a static site needs two things the host will not give you: a place to run code, and a way to check that the address belongs to the person who typed it. This page has a complete double opt-in flow in one Worker with no database, and says plainly what it leaves out.

What it does

  1. The visitor enters an address. The Worker sends one email to it with a link.
  2. The link opens a page with a button. Pressing the button confirms. Opening the link alone does nothing, because mail scanners and link previews open every link in a message, and a link that subscribes on open lets a scanner confirm a stranger's address.
  3. On confirmation the Worker emails you the confirmed address. That inbox is your list; copy addresses into your newsletter tool, or a spreadsheet, when you want them.

The link carries a signed, expiring token (HMAC-SHA-256, 48 hours), so the Worker keeps no state. No account at a mailing-list service is needed to try it. Both Cloudflare and Resend have free plans; check their current limits and sending rules before you rely on them.

Before you start

  • A Cloudflare account, Node 18 or newer, and npx wrangler.
  • A Resend account and API key, with your sending domain verified there.
  • A long random string for TOKEN_SECRET (24 characters or more).

1. The form and script

The email field has a visible label and autocomplete="email", the status line is a live region, and the extra website field is a honeypot that people never see.

<form id="wl" novalidate>
  <p><label for="wl-email">Email address</label><br><input id="wl-email" name="email" type="email" autocomplete="email" required></p>
  <p style="position:absolute;left:-9999px" aria-hidden="true"><label>Leave empty <input name="website" tabindex="-1" autocomplete="off"></label></p>
  <p id="wl-status" role="status" aria-live="polite"></p>
  <button type="submit">Join the waitlist</button>
</form>
<script>
document.getElementById("wl").addEventListener("submit", async (e) => {
  e.preventDefault();
  const f = e.target, s = document.getElementById("wl-status");
  s.textContent = "Sending...";
  try {
    const r = await fetch("https://waitlist.YOUR-NAME.workers.dev/subscribe", {
      method: "POST", headers: { "content-type": "application/json" },
      body: JSON.stringify(Object.fromEntries(new FormData(f))),
    });
    s.textContent = r.ok ? "Check your inbox and press the button in our email." : "That did not work. Please check the address and try again.";
    if (r.ok) f.reset();
  } catch { s.textContent = "That did not work. Please try again."; }
});
</script>

2. The Worker

Save as worker.js.

// Double opt-in waitlist Worker: Cloudflare Workers + Resend. No database.
// Secrets: RESEND_API_KEY, TOKEN_SECRET (24+ random characters).
// Variables: SITE_ORIGIN (https://example.com), OWNER_EMAIL, FROM_EMAIL.
const enc = new TextEncoder();
const b64u = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
const unb64u = (s) => atob(s.replace(/-/g, "+").replace(/_/g, "/"));
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
const EMAIL = /^[^\s@,;<>"']+@[^\s@,;<>"']+\.[^\s@,;<>"']+$/;

async function sign(env, text) {
  const key = await crypto.subtle.importKey("raw", enc.encode(env.TOKEN_SECRET), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
  return b64u(await crypto.subtle.sign("HMAC", key, enc.encode(text)));
}
async function makeToken(env, email) {
  const body = b64u(enc.encode(email + "|" + (Date.now() + 48 * 3600e3)));
  return body + "." + (await sign(env, body));
}
async function readToken(env, token) {
  const [body, mac] = String(token || "").split(".");
  if (!body || !mac) return null;
  const good = await sign(env, body);
  if (good.length !== mac.length) return null;
  let diff = 0;
  for (let i = 0; i < good.length; i++) diff |= good.charCodeAt(i) ^ mac.charCodeAt(i);
  if (diff) return null;
  const [email, exp] = unb64u(body).split("|");
  return Number(exp) > Date.now() ? email : null;
}
const send = (env, msg) => fetch("https://api.resend.com/emails", {
  method: "POST",
  headers: { authorization: "Bearer " + env.RESEND_API_KEY, "content-type": "application/json" },
  body: JSON.stringify({ from: env.FROM_EMAIL, ...msg }),
});
const json = (body, status, origin = "") => new Response(JSON.stringify(body), {
  status, headers: { "content-type": "application/json", "access-control-allow-origin": origin, vary: "Origin" },
});
const page = (title, inner) => new Response(
  `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(title)}</title><main style="max-width:32rem;margin:3rem auto;padding:0 1rem;font:1.1rem/1.5 system-ui">${inner}</main>`,
  { headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "referrer-policy": "no-referrer" } });

export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    const origin = request.headers.get("Origin") || "";

    if (url.pathname === "/confirm") {
      if (request.method === "GET") {            // GET only shows a button, so link scanners confirm nothing
        const email = await readToken(env, url.searchParams.get("t"));
        if (!email) return page("Link expired", "<h1>This link has expired</h1><p>Please sign up again.</p>");
        return page("Confirm", `<h1>Confirm ${esc(email)}</h1><form method="post"><input type="hidden" name="t" value="${esc(url.searchParams.get("t"))}"><button style="font:inherit;padding:.6rem 1.2rem">Yes, add me</button></form>`);
      }
      if (request.method === "POST") {
        const email = await readToken(env, (await request.formData()).get("t"));
        if (!email) return page("Link expired", "<h1>This link has expired</h1><p>Please sign up again.</p>");
        const r = await send(env, { to: [env.OWNER_EMAIL], subject: "Confirmed: " + email.replace(/[\r\n]+/g, " "), text: "Confirmed by button press: " + email });
        if (!r.ok) return page("Try again", "<h1>Something went wrong</h1><p>Please try the button again in a minute.</p>");
        return page("Thanks", "<h1>You are on the list</h1>");
      }
      return new Response(null, { status: 405 });
    }

    if (request.method === "OPTIONS") {
      return new Response(null, { status: 204, headers: {
        "access-control-allow-origin": origin === env.SITE_ORIGIN ? origin : "",
        "access-control-allow-methods": "POST", "access-control-allow-headers": "content-type", vary: "Origin" } });
    }
    if (request.method !== "POST" || url.pathname !== "/subscribe") return json({ error: "not_found" }, 404);
    if (origin !== env.SITE_ORIGIN) return json({ error: "origin" }, 403);
    let data;
    try { data = await request.json(); } catch { return json({ error: "bad_json" }, 400, origin); }
    if (!data || typeof data !== "object") return json({ error: "bad_json" }, 400, origin);
    if (data.website) return json({ ok: true }, 200, origin);          // honeypot: look like success
    const email = String(data.email || "").trim().slice(0, 254);
    if (!EMAIL.test(email)) return json({ error: "invalid" }, 400, origin);

    const link = new URL(request.url).origin + "/confirm?t=" + encodeURIComponent(await makeToken(env, email));
    const r = await send(env, {
      to: [email], subject: "Please confirm your email address",
      text: "Someone entered this address on " + env.SITE_ORIGIN + ". If it was you, open this link and press the button:\n\n" + link + "\n\nIf it was not you, ignore this message; nothing else will be sent.",
    });
    if (!r.ok) return json({ error: "send_failed" }, 502, origin);
    return json({ ok: true }, 200, origin);
  },
};

We ran this exact file in Node with a mocked Resend call. A valid address produced one email to that address. A foreign origin, a filled honeypot, a malformed body and four malformed addresses (including one with a line break and a Bcc header) produced no email. Opening the confirm link with GET sent nothing; pressing the button sent one email to the owner; a tampered token and a token 49 hours old were refused. We did not deploy this page's copy to Cloudflare, so send yourself a test signup after you deploy.

3. Deploy

name = "waitlist"
main = "worker.js"
compatibility_date = "2025-01-01"

[vars]
SITE_ORIGIN = "https://example.com"
OWNER_EMAIL = "[email protected]"
FROM_EMAIL = "Example <[email protected]>"
npx wrangler secret put RESEND_API_KEY
npx wrangler secret put TOKEN_SECRET
npx wrangler deploy

Put the printed Worker address into the script, publish, and sign up with an address of your own.

What this version does not stop

  • It can still be used to mail a stranger, repeatedly. There is no memory of who was emailed, so a script can post the same victim's address a thousand times and your domain sends a thousand confirmations. The honeypot and origin check stop only careless bots. This is subscription bombing, and the fix needs a counter that survives between requests (one email per address per day, a per-IP limit and a daily ceiling), kept somewhere that updates atomically, such as a Durable Object. The guide Stop subscription bombing on a static site's signup form explains the attack and the measures.
  • No proof of work or bot check. The free proof-of-work tester shows what that costs on your own device.
  • No unsubscribe, no list. The list is your inbox. Before you send a newsletter you need a way for people to leave, and in many places a legal duty to provide one.
  • A resent confirmation is a second email. A visitor who signs up twice receives two messages.

If your form is a contact form, start with the contact form version, which only ever emails you. To check your markup, paste it into the form checker.

If you want the missing parts done and tested

Signup Shield Kit ($15, once) adds exactly what this page leaves out: per-address, per-IP and daily limits in a Durable Object, proof of work, subscriber storage with unsubscribe and CSV export, three sample sites, docs and an attack simulator. Everything above is free to use as it is.

More from the catalogue

Other small tools

See everything →