# Build scanner-safe double opt-in

> Build a double opt-in flow that uses one-time hashed tokens and requires a POST, so email scanners cannot confirm subscriptions by opening links.

Author: [Flavio Copes](https://flaviocopes.com/about/) | Published: 2026-08-05 | Updated: 2026-08-03 | Topics: [Cloudflare](https://flaviocopes.com/tags/cloudflare/) | Canonical: https://flaviocopes.com/scanner-safe-double-opt-in/

A double opt-in link should not confirm an email address when someone opens it.

This sounds strange at first. Opening the link is the usual confirmation step.

But many email providers and security tools open links before the recipient does. They check the destination for malware and phishing.

If a `GET` request changes the subscriber from pending to confirmed, the scanner can confirm the address.

The fix is to split confirmation into two steps:

1. `GET /confirm?token=...` displays a confirmation page
2. `POST /confirm` performs the confirmation

The first request is read-only. GET-only scanners cannot confirm the address, while the visible POST adds an intentional user step.

An automated client can still submit the form. The one-time capability token remains the authorization boundary.

## Generate a one-time token

Start with 32 random bytes:

~~~js
function createToken() {
  const bytes = new Uint8Array(32)
  crypto.getRandomValues(bytes)

  return btoa(String.fromCharCode(...bytes))
    .replaceAll('+', '-')
    .replaceAll('/', '_')
    .replaceAll('=', '')
}
~~~

This produces a 43-character base64url token.

Send the original token by email. Do not store it in the database.

Store an HMAC instead:

~~~js
async function hashToken(secret, token) {
  const encoder = new TextEncoder()
  const key = await crypto.subtle.importKey(
    'raw',
    encoder.encode(secret),
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['sign']
  )

  const signature = await crypto.subtle.sign(
    'HMAC',
    key,
    encoder.encode(`confirm:${token}`)
  )

  return [...new Uint8Array(signature)]
    .map(byte => byte.toString(16).padStart(2, '0'))
    .join('')
}
~~~

If the database leaks, the stored value cannot be used as a confirmation link.

The `confirm:` prefix also separates this token from other capabilities signed with the same secret.

## Make the GET request read-only

The confirmation link should only display a form:

~~~html
<form method="post" action="/confirm">
  <input type="hidden" name="token" value="THE_TOKEN" />
  <button type="submit">Confirm my place</button>
</form>
~~~

Do not update the database in the page's `GET` handler.

Return the page with token-leakage controls:

~~~js
return new Response(html, {
  headers: {
    'content-type': 'text/html; charset=utf-8',
    'cache-control': 'no-store',
    'referrer-policy': 'no-referrer',
    'x-robots-tag': 'noindex, nofollow',
    'content-security-policy':
      "default-src 'none'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'"
  }
})
~~~

Do not load analytics, images, fonts, or other third-party resources on this page. Configure access logs to remove the `token` query parameter too.

This protects the flow from:

- email security scanners
- link previews
- chat applications that fetch shared URLs
- browser prefetching

These systems can load the page without changing subscriber state.

## Confirm with one atomic query

Hash the submitted token, then update only a matching pending subscriber:

~~~sql
UPDATE subscribers
SET
  status = 'confirmed',
  confirmed_at = unixepoch(),
  confirmation_token_hash = NULL,
  confirmation_expires_at = NULL
WHERE status = 'pending'
  AND confirmation_token_hash = ?
  AND confirmation_expires_at >= ?
RETURNING id;
~~~

This query gives us three useful properties.

The token must exist. It must not be expired. The subscriber must still be pending.

It is also single-use. The successful update removes the hash, so the same token cannot confirm anything again.

Store `confirmation_expires_at` as integer Unix seconds and bind the current Unix time to the final placeholder. This matches `unixepoch()` in `confirmed_at` and keeps confirmation and cleanup comparisons consistent.

## Keep the token in the POST body

The email link needs the token in its query string so the GET handler can build the page. The form action does not:

~~~html
<form method="post" action="/confirm">
  <input type="hidden" name="token" value="THE_TOKEN" />
</form>
~~~

Read the token only from the form body:

~~~js
const token = form.get('token')

if (!validToken(token)) {
  return new Response('Invalid confirmation request', {
    status: 400
  })
}
~~~

Putting the token in the POST URL exposes it to another request log and adds no protection.

## Do we need CSRF protection?

The token itself authorizes this one action.

There is no logged-in user and no ambient session cookie. A different site cannot guess a 256-bit token and confirm an arbitrary address.

An `Origin` check does not add much here. It can also reject legitimate requests passing through a custom domain or proxy.

This does not apply to an admin form. An authenticated admin action still needs normal CSRF protection.

## Expire pending requests

Give confirmation links a short lifetime. Twenty-four hours is a reasonable default.

Then delete expired pending rows:

~~~sql
DELETE FROM subscribers
WHERE status = 'pending'
  AND confirmation_expires_at < ?;
~~~

Run this from a scheduled job.

The result is a double opt-in flow that survives scanners, limits the value of leaked data, and confirms each token once.
