How to build a double opt-in flow

A double opt-in (confirmed opt-in) flow adds one step to signup: after someone submits their address you store it as pending, send a single confirmation email with a signed link, and only create a real subscriber when they click it. Verify the address before you send that confirmation, sign the token so no database row is needed, and handle expiry with a clean resend. The result is a list of addresses that are real, reachable, and provably consented — the cleanest audience you can build.

Written & reviewed by the VerifyAnyEmail team · Last reviewed July 2026

The short version: verify the address, store it as pending, email a signed confirmation link, and activate the subscriber only on click. Double opt-in is the strongest signal of consent you can collect, and it structurally filters out bots, typos, and people who entered someone else's address — because none of them will click the link. The two details that make it robust are (1) verifying the mailbox before the confirmation send so you don't bounce or hit a trap, and (2) using a signed, expiring token so you don't need to persist secrets. Here is the whole flow.

Why confirmed opt-in is worth the extra step

Single opt-in adds anyone who submits the form — including bots, typo'd addresses, and people who fat-fingered a stranger's inbox. Double opt-in requires a deliberate click from someone who can actually read that inbox, which buys you three things at once. It gives you genuine, provable consent (the click, with a timestamp, is your record — relevant to data-protection obligations). It protects deliverability, because an audience that all confirmed engages better and complains less, and because typo and spam-trap addresses never get confirmed. And it keeps your list clean at the source instead of forcing you to clean it later. The cost is one email and a small drop in raw signup count — a trade almost every serious sender makes gladly. For where this sits relative to other controls, see validation vs verification vs double opt-in.

Verify the address BEFORE the confirmation send

This is the step that separates a good double opt-in flow from a naive one. Double opt-in requires sending an email — and if the submitted address is a typo domain, a long-dead mailbox, or a recycled spam trap, that very first confirmation email bounces or lands in the trap, damaging your reputation before consent is even in question. So run verification first, at the /subscribe handler, and only send the confirmation to addresses that aren't confirmed dead. Block a definite undeliverable right there (usually a typo — offer the fix), and let risky/unknown proceed, because for those the confirmation click is itself the tiebreaker. You can wire the API in with the Node.js verification client, or sanity-check addresses by hand in the Email Verifier.

Sign the token — no database row for the secret

The confirmation link needs a token that proves "this request came from us and is still fresh" without you storing a random secret per signup. An HMAC-signed token does exactly that: encode the email and a timestamp, sign it with a server-only secret, and put the result in the link. On click you recompute the signature and check it in constant time, then check the timestamp for expiry. No token table, nothing to clean up, and a tampered or forged link fails the signature check.

// Create a signed, expiring token. NO database row needed to store it —
// the signature proves it's genuine and the timestamp proves it's fresh.
import crypto from 'crypto';

const SECRET = process.env.CONFIRM_SECRET;      // long, random, server-only
const TTL_MS = 24 * 60 * 60 * 1000;             // 24 hours

function signConfirmToken(email) {
  const payload = `${email}:${Date.now()}`;
  const b64 = Buffer.from(payload).toString('base64url');
  const sig = crypto.createHmac('sha256', SECRET).update(b64).digest('base64url');
  return `${b64}.${sig}`;                        // goes in the confirm link
}

function verifyConfirmToken(token) {
  const [b64, sig] = String(token).split('.');
  if (!b64 || !sig) return { ok: false, reason: 'malformed' };

  // Recompute the signature and compare in CONSTANT TIME.
  const expected = crypto.createHmac('sha256', SECRET).update(b64).digest('base64url');
  const a = Buffer.from(sig), b = Buffer.from(expected);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return { ok: false, reason: 'bad_signature' };
  }

  const [email, tsStr] = Buffer.from(b64, 'base64url').toString().split(':');
  if (Date.now() - Number(tsStr) > TTL_MS) return { ok: false, reason: 'expired' };
  return { ok: true, email };
}

Two details matter for security. Compare signatures with timingSafeEqual, not ===, so an attacker can't use response timing to forge a valid signature byte by byte. And keep CONFIRM_SECRET long, random, and strictly server-side. (The same constant-time discipline shows up in verifying webhook signatures.)

The three-handler flow

Signup verifies then emails; the confirm link activates; a resend re-issues a fresh token. The user record stays pending until the click — an unconfirmed address is never a subscriber and never gets marketing mail.

// STEP 1 — signup. VERIFY the address BEFORE sending the confirmation email.
app.post('/subscribe', async (req, res) => {
  const email = String(req.body.email ?? '').trim();

  // Verify first: don't waste a confirmation send on a dead or trap address.
  const result = await verifyEmail(email, { timeoutMs: 4000 });
  if (result.status === 'undeliverable') {
    return res.status(422).json({ error: 'That address does not exist — check for a typo.' });
  }
  // risky / unknown proceed: the confirmation click is the real proof of intent.

  // Store as PENDING (not subscribed) with the request time for consent records.
  await db.upsertPending({ email, requestedAt: new Date(), ip: req.ip });

  // Send ONE confirmation email containing the signed link.
  const token = signConfirmToken(email);
  const link = `https://example.com/confirm?token=${token}`;
  await sendConfirmationEmail(email, link);

  return res.json({ ok: true, message: 'Check your inbox to confirm.' });
});

// STEP 2 — the confirm link. Clicking it is what creates a real subscriber.
app.get('/confirm', async (req, res) => {
  const check = verifyConfirmToken(req.query.token);
  if (!check.ok) {
    // Expired or bad: offer to resend, don't just error out.
    return res.status(400).render('confirm-failed', { reason: check.reason });
  }
  // Consent proven: activate, and record WHEN and HOW for your audit trail.
  await db.activateSubscriber({ email: check.email, confirmedAt: new Date() });
  return res.render('confirm-success');
});

// STEP 3 — resend. Re-issue a FRESH token; never resurrect the old one.
app.post('/resend', async (req, res) => {
  const email = String(req.body.email ?? '').trim();
  if (await db.isPending(email)) {
    await sendConfirmationEmail(email, `https://example.com/confirm?token=${signConfirmToken(email)}`);
  }
  // Always respond the same way — don't reveal whether the address is on file.
  return res.json({ ok: true, message: 'If that address is pending, we re-sent the link.' });
});

Handling expiry and resend

Confirmation links must expire — a link that works forever is a weaker consent signal and a small security liability — so a 24-hour (or similar) TTL baked into the signed token is right. That makes a clean resend essential, because some people confirm a day late. When a link is expired or the token is bad, don't dead-end the user on an error page: show a "this link expired — resend it?" screen that issues a brand-new token. Never try to resurrect the old one; always sign a fresh timestamp. And make the resend endpoint respond identically whether or not the address is actually pending, so it can't be used to probe who's on your list. Expire the pending record itself after a reasonable window (say, a week of no confirmation) so abandoned signups don't linger as data you have no consent to keep.

The confirmation email itself

Keep it minimal and unmistakable. State clearly that they signed up and must click to confirm, make the link the obvious primary action, and set expectations for what they'll receive. Send it from an authenticated domain (SPF, DKIM, DMARC) so the confirmation lands in the inbox rather than spam — a double opt-in flow whose confirmation email gets filtered silently kills your signups. Because it's transactional, it should go out immediately and from a stream you keep clean; the transactional vs marketing guide covers why to keep it separate from campaign sends.

Common mistakes

  • Sending the confirmation without verifying first. You bounce typos and hit traps on the one email double opt-in forces you to send.
  • Activating on pending instead of on click. If a pending record can receive marketing mail, you've built single opt-in with extra steps.
  • Non-expiring or non-signed tokens. A guessable or eternal link is weak consent and a security hole.
  • Dead-ending expired links. Offer a resend; a lot of people confirm late.
  • Leaking list membership on resend. Respond the same way for known and unknown addresses.

Build it this way and every address on your active list is well-formed, verified reachable, and confirmed by a real human who wanted it — with a timestamped click as your consent record.

Check your domain in seconds

Run the free diagnostics referenced in this guide — no sign-up needed.