Skip to content

tutorials

Build a newsletter signup with double opt-in on Cloudflare

July 6, 2026

#cloudflare#newsletter#double-opt-in#d1#workers
Build a newsletter signup with double opt-in on Cloudflare
Visual: Nyx Datacenter on okslop

You want an email capture form on your site that actually verifies the address before adding it to your list. On Cloudflare that means a Worker, a place to store subscribers, a confirmation email you can't send over SMTP, and a token scheme that a bot can't forge. None of the pieces are hard, but stitched together they are fiddly, and the quickstart tutorials skip the parts that matter.

Single opt-in is one INSERT: someone types an address, you store it, done. It is also how you end up mailing spam traps, typo'd addresses, and people who never asked, all of which quietly poison your sender reputation. Double opt-in adds one step: the address is only real once its owner clicks a confirmation link. That single extra hop is what keeps your list clean and your consent provable.

Let's build the whole thing on Cloudflare from scratch: a D1 table, a Worker with /subscribe and /confirm routes, a signed confirmation token, bot protection, and the unglamorous edge cases (replays, re-subscribes, address enumeration). Then, at the end, the shortcut if you'd rather not own the plumbing.

What is double opt-in and why bother?

Double opt-in means a subscriber must confirm their address by clicking a link before they are added to your active list. The form submission creates a pending record; only the click promotes it to confirmed. This proves two things at once: the address exists and accepts mail, and the person who owns it actually wanted in.

The payoff is concrete. A confirmed list bounces less, so mailbox providers trust your domain more, so more of your mail reaches the inbox instead of spam. It also gives you a timestamped consent record, which is the cleanest answer to a GDPR or CAN-SPAM question about how someone ended up on your list. The cost is one extra email and a confirmation page. For any list you care about, that trade is worth it.

The flow has exactly three states and two transitions:

[form submit] --> pending --> [confirm click] --> confirmed
                    |
                    +-- (expires / never confirmed) --> stays pending, prune later

Everything below implements those two arrows.

How do I store subscribers in D1?

Use one table keyed by email, with a status column that is either pending or confirmed, plus timestamps for when they signed up and when they confirmed. D1 is SQLite, so this is ordinary SQL.

-- migrations/0001_subscribers.sql
CREATE TABLE subscribers (
  id            TEXT PRIMARY KEY,        -- a generated id, e.g. ULID
  email         TEXT NOT NULL,
  status        TEXT NOT NULL DEFAULT 'pending',  -- 'pending' | 'confirmed' | 'unsubscribed'
  created_at    INTEGER NOT NULL,
  confirmed_at  INTEGER                  -- NULL until they click the link
);

-- Email must be unique, case-insensitively. Store it lowercased and index it.
CREATE UNIQUE INDEX subscribers_email_idx ON subscribers (email);

Two decisions worth making now. Store the email already lowercased and trimmed, because Felix@Example.com and felix@example.com are the same mailbox and a case-sensitive unique index will happily store both. And keep confirmed_at separate from status: the timestamp is your consent proof, and you will want it even after the status changes.

Apply the migration with Wrangler:

npx wrangler d1 migrations apply newsletter --remote
Migrations to be applied:
  0001_subscribers.sql
✔ 1 migration applied (0.21 sec)

If you want the reasoning behind a fuller email schema (threads, folders, bodies), that is its own topic in designing a D1 schema for an email inbox. For a signup list, the single table above is enough.

How do I build the subscribe endpoint?

POST /subscribe validates the address, upserts it as pending, generates a signed confirmation token, and emails the confirmation link. The key word is pending: this route never adds anyone to your real list, it only records intent and sends a link.

Here is the route, with the validation and the upsert:

// src/index.ts
interface Env {
  DB: D1Database;
  CONFIRM_SECRET: string;   // HMAC secret, set via `wrangler secret put`
  EMAIL_API_KEY: string;    // your HTTPS email sender's key
  SITE_URL: string;         // e.g. "https://yourdomain.com"
}

const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

export default {
  async fetch(req: Request, env: Env): Promise<Response> {
    const url = new URL(req.url);
    if (req.method === "POST" && url.pathname === "/subscribe") {
      return subscribe(req, env);
    }
    if (req.method === "GET" && url.pathname === "/confirm") {
      return confirm(url, env);
    }
    return new Response("Not found", { status: 404 });
  },
} satisfies ExportedHandler<Env>;

async function subscribe(req: Request, env: Env): Promise<Response> {
  const form = await req.formData();
  const email = String(form.get("email") ?? "").trim().toLowerCase();

  // Honeypot: a hidden field real users leave empty. Bots fill it.
  if (String(form.get("company") ?? "") !== "") {
    return ok(); // pretend success, do nothing
  }
  if (!EMAIL_RE.test(email) || email.length > 254) {
    return new Response("Invalid email", { status: 400 });
  }

  const now = Date.now();
  const id = crypto.randomUUID();

  // Upsert: if the address already exists, don't create a duplicate or
  // reset a confirmed subscriber back to pending.
  await env.DB.prepare(
    `INSERT INTO subscribers (id, email, status, created_at)
     VALUES (?1, ?2, 'pending', ?3)
     ON CONFLICT(email) DO NOTHING`,
  )
    .bind(id, email, now)
    .run();

  // Read back the row so we send the link for the *existing* id if it was there.
  const row = await env.DB.prepare(
    `SELECT id, status FROM subscribers WHERE email = ?1`,
  )
    .bind(email)
    .first<{ id: string; status: string }>();

  // Already confirmed? Don't re-send a confirmation; just succeed quietly.
  if (row && row.status !== "confirmed") {
    const token = await signToken(row.id, env.CONFIRM_SECRET);
    await sendConfirmationEmail(env, email, token);
  }

  // Always return the same response, whether the address is new, pending,
  // or already confirmed. Revealing which leaks who is on your list.
  return ok();
}

function ok(): Response {
  return new Response("Check your email to confirm.", { status: 202 });
}

The non-obvious part is the last comment. If you return "already subscribed!" for known addresses and "check your email!" for new ones, you have built an email enumeration oracle: anyone can probe which addresses are on your list. Return the same neutral response either way.

How do I generate a confirmation token that bots can't forge?

Sign the token with HMAC over the subscriber id and an expiry, using a secret only your Worker knows. The link then carries everything needed to verify it without a database lookup, and a bot can't mint a valid one because it doesn't have the secret.

// A token is "<id>.<expiry>.<signature>", base64url-encoded parts.
async function signToken(id: string, secret: string): Promise<string> {
  const exp = Date.now() + 1000 * 60 * 60 * 48; // valid 48 hours
  const payload = `${id}.${exp}`;
  const sig = await hmac(payload, secret);
  return `${b64url(payload)}.${b64url(sig)}`;
}

async function verifyToken(
  token: string,
  secret: string,
): Promise<{ id: string } | null> {
  const [payloadB64, sigB64] = token.split(".");
  if (!payloadB64 || !sigB64) return null;

  const payload = unb64url(payloadB64);
  const expected = await hmac(payload, secret);

  // Constant-time compare; reject on any mismatch.
  if (!timingSafeEqual(unb64urlBytes(sigB64), expected)) return null;

  const [id, expStr] = payload.split(".");
  if (!id || !expStr) return null;
  if (Date.now() > Number(expStr)) return null; // expired

  return { id };
}

async function hmac(data: string, secret: string): Promise<Uint8Array> {
  const key = await crypto.subtle.importKey(
    "raw",
    new TextEncoder().encode(secret),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign"],
  );
  const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data));
  return new Uint8Array(sig);
}

function timingSafeEqual(a: Uint8Array, b: Uint8Array): boolean {
  if (a.length !== b.length) return false;
  let diff = 0;
  for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
  return diff === 0;
}

// Base64url helpers (omitted-free, fully runnable)
function b64url(s: string): string {
  return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function unb64url(s: string): string {
  return atob(s.replace(/-/g, "+").replace(/_/g, "/"));
}
function unb64urlBytes(s: string): Uint8Array {
  return Uint8Array.from(unb64url(s), (c) => c.charCodeAt(0));
}

The crypto.subtle and crypto.randomUUID APIs are part of the Workers runtime, so there is no library to import. Set the secret out of band, never in code:

npx wrangler secret put CONFIRM_SECRET
✔ Enter a secret value: ********************************
✨ Success! Uploaded secret CONFIRM_SECRET

A signed token means /confirm does not need a separate tokens table or a database read just to validate the link. The trade-off: you cannot revoke a single token before it expires without a denylist. For a 48-hour confirmation link that is fine. If you need revocable tokens, store a random token row in D1 instead and look it up; both designs are valid.

How do I send the confirmation email from a Worker?

Send it over an HTTPS email API, because Cloudflare Workers cannot open outbound port 25 for direct-to-MX SMTP. There is no port 25 outbound at the edge, so the usual Nodemailer-over-SMTP approach you would use on a VPS does not exist here. You POST to an email provider's HTTP endpoint instead.

async function sendConfirmationEmail(
  env: Env,
  email: string,
  token: string,
): Promise<void> {
  const confirmUrl = `${env.SITE_URL}/confirm?t=${encodeURIComponent(token)}`;

  const res = await fetch("https://api.your-email-provider.example/v1/send", {
    method: "POST",
    headers: {
      "content-type": "application/json",
      authorization: `Bearer ${env.EMAIL_API_KEY}`,
    },
    body: JSON.stringify({
      from: "Newsletter <hello@yourdomain.com>",
      to: email,
      subject: "Confirm your subscription",
      html: `<p>Thanks for subscribing. Confirm your address to start receiving emails:</p>
             <p><a href="${confirmUrl}">Confirm my subscription</a></p>
             <p>If you didn't sign up, ignore this email and nothing happens.</p>`,
    }),
  });

  if (!res.ok) {
    // Log and surface; don't silently swallow a failed send, or the user
    // never gets a link and you have a stuck 'pending' row.
    const detail = await res.text();
    console.error("confirmation send failed", res.status, detail);
    throw new Error(`send failed: ${res.status}`);
  }
}

If you run this against the wrong endpoint or an unverified sending domain, the failure is loud and specific:

confirmation send failed 403 {"error":"domain yourdomain.com is not verified for sending"}

That error means your SPF and DKIM records are not in place yet for the sending domain. Fix the DNS before you ship the form, or every confirmation email will either fail like this or land in spam. The signup flow is only as good as your deliverability underneath it.

GET /confirm verifies the token, flips the matching row from pending to confirmed, and records the timestamp. It must also be safe to click twice, because people double-click and mail clients pre-fetch links.

async function confirm(url: URL, env: Env): Promise<Response> {
  const token = url.searchParams.get("t") ?? "";
  const verified = await verifyToken(token, env.CONFIRM_SECRET);
  if (!verified) {
    return new Response("This confirmation link is invalid or expired.", {
      status: 400,
    });
  }

  // Idempotent confirm: only update rows still pending. A second click
  // matches zero rows (already confirmed) and is harmless.
  const res = await env.DB.prepare(
    `UPDATE subscribers
        SET status = 'confirmed', confirmed_at = ?2
      WHERE id = ?1 AND status = 'pending'`,
  )
    .bind(verified.id, Date.now())
    .run();

  // Whether we just confirmed them or they were already confirmed, show success.
  if (res.meta.changes === 0) {
    const exists = await env.DB.prepare(
      `SELECT 1 FROM subscribers WHERE id = ?1 AND status = 'confirmed'`,
    )
      .bind(verified.id)
      .first();
    if (!exists) {
      return new Response("This subscription could not be found.", { status: 404 });
    }
  }

  return new Response("You're confirmed. Welcome aboard.", {
    status: 200,
    headers: { "content-type": "text/plain" },
  });
}

The WHERE id = ?1 AND status = 'pending' clause is what makes this idempotent and replay-safe. The first valid click changes one row. A second click (or a mail-client link pre-fetch, which is real and will fire your /confirm route before the human ever clicks) matches zero pending rows and changes nothing, so nobody gets double-confirmed and no error is thrown. This is also why a signed token alone is not enough: the database state, not the token, is the source of truth for "already confirmed."

How do I stop bots from spamming the signup form?

Layer cheap defenses, because no single one is enough. The honeypot field in /subscribe above catches naive bots for free. Add Cloudflare Turnstile for the rest, and rate-limit the endpoint so it can't be hammered.

Turnstile gives you a token the form submits, which you verify server-side:

async function verifyTurnstile(token: string, secret: string, ip: string): Promise<boolean> {
  const res = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ secret, response: token, remoteip: ip }),
  });
  const data = await res.json<{ success: boolean }>();
  return data.success === true;
}

// In subscribe(), before the insert:
const ts = String(form.get("cf-turnstile-response") ?? "");
const ip = req.headers.get("cf-connecting-ip") ?? "";
if (!(await verifyTurnstile(ts, env.TURNSTILE_SECRET, ip))) {
  return new Response("Verification failed", { status: 403 });
}

For rate limiting, Cloudflare's built-in rate limiting rules or a small counter keyed by cf-connecting-ip in a KV namespace both work. And remember the structural defense you already have: because of double opt-in, even a bot that gets past every check only creates a pending row. It never becomes a real subscriber unless someone clicks the emailed link, which a bot signing up someone else's address cannot do. That is the deepest reason double opt-in is worth the extra step.

How do I handle unsubscribes and stay compliant?

Add an unsubscribe path and put a one-click unsubscribe header on every campaign you send. Confirming subscribers is half the consent story; letting them leave instantly is the other half, and for bulk senders it is now mandatory, not optional.

The unsubscribe route mirrors confirm: a signed token identifying the subscriber, and a status flip to unsubscribed.

// GET /unsubscribe?t=<signed token>
const verified = await verifyToken(token, env.CONFIRM_SECRET);
if (verified) {
  await env.DB.prepare(
    `UPDATE subscribers SET status = 'unsubscribed' WHERE id = ?1`,
  ).bind(verified.id).run();
}

The header that makes Gmail and Yahoo show a one-click unsubscribe button, and that bulk-sender rules require, is its own careful implementation covered in implementing one-click unsubscribe and List-Unsubscribe headers. Wire it up before you send to a real list, not after the first complaint.

Common failure modes

Confirmation email never arrives

Either the send is failing (check the Worker logs for the provider error, as in the 403 above) or it is landing in spam because the sending domain's SPF and DKIM are not set up. The signup flow can't fix bad deliverability underneath it.

Some addresses got confirmed twice / threw an error on the second click

Your confirm update did not scope to status = 'pending'. Mail clients pre-fetch links, so /confirm fires before the human clicks. Make the update idempotent.

Anyone can tell which emails are on the list

Your /subscribe returns different responses for known versus unknown addresses. Return one neutral response regardless.

The form is full of fake signups

Add Turnstile and a honeypot, and lean on the double opt-in: fake pending rows that never confirm cost you nothing and can be pruned on a schedule.

The shortcut: subscribe and confirm in Tinbox

Everything above is the right architecture, and it is also a real amount to own: a D1 table, two Worker routes, an HMAC token scheme, an HTTPS sender with verified DNS, bot protection, idempotent confirms, and an unsubscribe path. If you'd rather not maintain the glue, this is exactly the flow Tinbox ships, running in your own Cloudflare account.

npx tinbox init yourdomain.com

Tinbox deploys the subscriber store in your own D1, the confirmation and unsubscribe routes, the signed-token logic, and the HTTPS sender (with the SPF and DKIM records generated for you). A double opt-in signup becomes a call rather than a checklist:

import { tinbox } from "tinbox/cf";

// Records a pending subscriber and sends the confirmation email.
// The /confirm and /unsubscribe routes are already wired in your deploy.
await tinbox(env).audience.subscribe({
  email,
  list: "newsletter",
  doubleOptIn: true,        // pending until they click the confirmation link
});

The confirmation still goes out over HTTPS, because Cloudflare Workers cannot open outbound port 25 for direct-to-MX SMTP, which is the whole reason edge email needs an HTTPS-based sender. The subscriber data, the consent timestamps, and the list all live in your account, not a vendor's, so growing the list does not grow a per-contact bill. If that pricing model is what pushed you here, here is the case against per-contact pricing, with the wider field surveyed in flat-rate newsletter platform alternatives and the tools for owning your newsletter on your own domain. And when you send to the confirmed list, the actual batched delivery uses the same Queues pipeline described in sending email reliably at volume with Cloudflare Queues.

Deploy this yourself: npx tinbox init yourdomain.com. The full subscribe, confirm, and audience configuration is in the Tinbox docs.

Frequently asked questions

What is double opt-in and why does a newsletter need it?

Double opt-in means a subscriber submits their address and then must click a confirmation link in an email before they are actually subscribed. It proves the address is real and that the owner consented, which keeps spam-trap and typo addresses off your list, protects sender reputation, and is the safe default under GDPR and CAN-SPAM. Single opt-in (subscribing on form submit alone) lets anyone sign up anyone.

How do I build a newsletter signup with double opt-in on Cloudflare?

Use a Worker with two routes. POST /subscribe inserts the address into a D1 table as status='pending' and emails a confirmation link containing a signed token. GET /confirm verifies the token, flips the row to status='confirmed', and marks the token used. Store subscribers in D1, send through an Email Service binding or HTTPS API, and sign the token with HMAC so it is tamper-proof and single-use.

Should the confirmation token be a random database row or a signed token?

Either works, but a signed HMAC token is simpler at the edge: it carries the subscriber id and an expiry in the URL and is verified with a secret, so you do not need a separate tokens table or a database read just to validate the link. Add a 'confirmed_at' or 'token_used_at' check in D1 so a token cannot be replayed after it succeeds once.

How do I stop bots from spamming my newsletter signup form?

Layer cheap defenses: a Cloudflare Turnstile challenge on the form, a hidden honeypot field that real users never fill, rate limiting by IP, and the double opt-in itself, which means a bot signup never becomes a real subscriber unless someone clicks the emailed link. Never confirm an address just because the form was submitted.

Is double opt-in required by GDPR?

GDPR does not name double opt-in specifically, but it requires demonstrable, freely given consent. Double opt-in produces exactly that record: a timestamped confirmation that the address owner clicked a link. It is the cleanest way to prove consent if you are ever asked, which is why it is the recommended default for EU subscribers.