A double opt-in signup without a CAPTCHA
How the Lab signup checks addresses without a CAPTCHA: a hidden honeypot, a per-IP limit, a signed 48-hour link and a confirm button instead of a confirm link.
- Issue
- #2
- Published
- Reading time
- 5 min
- Stack
- Claude Code, Nuxt, Supabase, Resend, Cloudflare
The problem
The Lab, the part of this site you're reading, has an email signup. If it saved any address it was given, anyone could sign up someone else, and those people would later get emails they never asked for. A CAPTCHA is the usual guard, but CAPTCHAs fail people with disabilities and add a step to every signup. I didn't want one on the site.
What I built
A double opt-in signup: an address becomes a subscriber only after its owner confirms through a link in an email. Checks the visitor never sees slow the bots down, and the email link does the rest. It runs on Nuxt server routes, Supabase and Resend, and it works without JavaScript.
Checks the visitor never sees
The signup route runs four steps, in this order:
- A honeypot: a field hidden from people, from the keyboard and from screen readers. Only a bot fills it in. It gets the normal answer, and nothing is saved.
- A per-IP limit: 3 signups per 10 minutes and 10 per day.
- The same email rule as the site's contact form.
- A lookup of the address, then one of four actions.
None of these stops a determined attacker. A botnet rotates IPs, people who share one IP can block each other, and the limit lives in the server's memory, so a restart resets it. They slow one source down. The real guard is the confirmation email: nobody becomes a subscriber without clicking through from their own inbox.
The same answer for every address
The answer never says who's subscribed. Every valid address gets "Check your inbox", whatever its state. This table sits at the top of the route:
| State | Action |
| ------------------------------------ | ----------------------------------- |
| Unknown | Insert a pending row, send |
| Pending, last email ≥ 10 min ago | Re-stamp, send (undo if it fails) |
| Pending, last email < 10 min ago | Nothing |
| Confirmed | Nothing |
The response time still differs. Only some states write to the database and send an email, so someone timing the answers could guess an address's state.
One address gets at most one email every 10 minutes, however many times it's entered. When two requests try to resend at the same moment, only one can. The update changes the row only if it still holds the old send time:
const query = `id=${eq(id)}&confirmed_at=is.null&confirmation_sent_at=${eq(from)}&select=id`
If the email fails to send, the new pending row is deleted and the visitor is told the signup didn't go through. Nothing is kept for an address that never got a link.
A signed link instead of a stored token
No token is stored in the database. The link carries its own data, the address and an expiry 48 hours after the email is sent, signed with an HMAC and a secret only the server holds. The purpose is part of what's signed, so a confirm link can't be reused for anything else:
function mac(secret: string, purpose: TokenPurpose, payload: string) {
return createHmac('sha256', secret).update(`lab-${purpose}.${payload}`).digest('base64url')
}
The check compares signatures in constant time with Node's timingSafeEqual, and Node's own crypto does all the work, with no extra dependency. Two trade-offs come with this. The link is signed, not encrypted, so anyone holding it can read the address inside. And rotating the secret breaks every link already sent.
Keying the limit on the real visitor
The site sits behind Cloudflare, so the connection comes from Cloudflare, not from the visitor. The limiter reads CF-Connecting-IP and never X-Forwarded-For, whose leftmost entry the visitor can set. IPv6 addresses count by their /64 network, because one connection usually holds a whole /64 and could use a new address for every request.
How it was built
Like the rest of this site, the signup was built with Claude Code in the workflow I described in How I ship with Claude Code. One planning session turned into a set of tickets, among them the database change, the signup route, the confirm pages and the IP fix for Cloudflare. Claude Code built each ticket in its own session, and /code-review checked each one against the repo's standards and the ticket before it was done.
The decisions were mine: double opt-in instead of a plain list, a cleanup that runs hourly instead of nightly, and deleting a new signup when its email fails. Claude Code wrote the code, checked the signup's paths against a mock database and a mock email service, and never wrote a test address to the live database. It also found the cache problem in the third point below, while building the Lab's pages, before any form existed.
What I learned
-
Many email scanners open links to check them. If opening the link confirmed the signup, addresses nobody clicked would be confirmed. So the link opens a page with one "Confirm signup" button, and only that button's POST confirms. It's a plain HTML form, so it works without JavaScript.
-
The cleanup schedule sets how long data really lives. Unconfirmed signups should go after 48 hours. A nightly delete job would let one live for about 72. The job runs every hour instead, so a pending signup is gone at most about 49 hours after its last email:
select cron.schedule( 'delete-pending-signups-older-than-48-hours', '23 * * * *', $$delete from public.subscribers where confirmed_at is null and confirmation_sent_at < now() - interval '48 hours'$$ ); -
A server cache can ignore the HTTP method. Nitro's route cache (version 2.13) keys on the URL, not on the method, and the Lab's pages are cached. A form that posted to a Lab page's URL could get a cached page back, and the server code would never run. So every form posts to an
/api/path that no cache rule matches, and the answer is a 303 redirect to a result page.