Hardening the email signup endpoint after the bots found it
Primary tool: Claude Code 2.1.219Built with: Claude Opus 5Stack: Buttondown, Netlify Functions, Cloudflare Turnstile, Astro 7.1last verified jul 2026
The endpoint was publicly POST-able, so anything on the internet could hand it an address. What that costs isn’t obvious until it happens:
- 33
- scraped addresses, in 65 hours
- 7
- strangers actually emailed
- 3
- hard bounces, on a then-empty list
Double opt-in doesn’t save you here, and this is the part worth internalising if you’re wiring a newsletter into anything public. Double opt-in stops list poisoning — nobody joins without confirming. It does nothing about list bombing, because the confirmation email is itself the payload. Strangers got mail from a newsletter they’d never heard of. Meanwhile the undeliverable ones became hard bounces charged against a sending reputation that had no subscribers to show for it. Left running, the realistic ending is Buttondown suspending the account — losing the asset before it exists.
What the traffic actually looked like
Every figure on this page comes from a masked subscriber export that is in the repository, so you can recompute them rather than take my word for it. The addresses are masked because the people behind them are scraping victims, not attackers — publishing them would inflict exactly the harm this build exists to stop.

Three things in it weren’t visible from the dashboard:
- It was a pool, not a host. 33 signups from 16 distinct IPs across 7 /24 subnets, the busiest three carrying 11, 8 and 6. Median gap between signups: 65 minutes. That’s machine-regular pacing, deliberately spread.
- 79% never got an email. 26 of the 33 were blocked by Buttondown’s own firewall before anything was sent. Only 7 were ever emailed, and 3 of those bounced. If you see a bounce rate quoted for this build, the denominator is 7 — 43% — not 33.
- One of them confirmed. An address from the wave clicked its confirmation link a day later. I can’t tell you whether that’s a real person who genuinely wanted the newsletter, a confused victim, or a corporate link-scanner following URLs automatically. All three produce an identical record. It’s left unclassified on purpose, and it means a confirmation isn’t proof of a human either.
That spread is also the retroactive explanation for two ideas that went nowhere. Rate limiting per IP has almost nothing to bite on at one signup an hour across seven subnets. And forwarding the visitor’s IP to Buttondown so its firewall can judge the subscriber — which sounds right, and which I chased hard — just hands it a different clean-looking address nearly every time.
The honest process
Shipped a fix whose test mocked away the only thing that matteredMy setup
This build starts where the previous one ended: an inline signup that looked verified and was silently failing in production. Visitors saw “check your inbox,” no subscriber was created, no email arrived. The local test had repointed the form at a dummy page — so the one thing that could break, a real cross-origin POST to Buttondown, was never exercised. “Verified locally” meant nothing.
Every browser-only path is a dead endTool limit
Three ways to submit straight from the browser, three walls. A cross-origin fetch
is CORS-blocked. A cross-site hidden-iframe submit gets stripped by browser tracking
protection — reproduced failing in Safari. A no-cors fetch reaches the server and
creates nothing. The anonymous embed endpoint also rate-limits under test traffic.
There is no reliable purely client-side integration; a server-side proxy isn’t the
elegant option, it’s the only one.
Called it fixed on evidence from the wrong machineMy setup
The proxy returned 400 on every call. First diagnosis: forward the visitor’s IP. A
couple of tests went green and I called it solved. Those greens came from curl
running on my own machine — a residential IP — not the datacenter path real users
take. A clean isolation test killed the theory outright: injecting a known-good
residential IP into the request body still 400d.
The outage was self-inflicted, and the error said soMy setup
The actual message, once I read it instead of inferring it: “This subscriber was blocked by your firewall.” Our own burst of test signups had tripped Buttondown’s Attack mode, which auto-escalates to IP-address auditing on “a surge of unactivated subscribers.” It then audits the connection IP — which for a server-side proxy is always the datacenter — so every signup became a permanent false positive, including my own address. Worth flagging for anyone about to launch: a surge of unactivated subscribers is also what a successful launch looks like.
Buttondown reports a firewall block as a bare 400Tool limit
The block surfaces with no indication that a user-configurable setting is responsible. Diagnosing it meant reading the API request log and correlating by IP. The setting that caused it, the log that revealed it, and the error that hid it are three different screens.
Claimed env vars don't need a redeploy. They do.My setup
Testing the Turnstile rejection path meant flipping the preview’s secret to
Cloudflare’s always-fail key. I stated no redeploy was needed because functions read
env per invocation. Wrong — Netlify injects env vars at deploy time. So the test ran
against the old secret, returned ok:true, and I nearly recorded a pass for a control
that had never run. It also created a stray subscriber. After a redeploy the same test
returned 403 turnstile_failed with nothing created: the actual proof.
What worked
Controls in a deliberate order, every local check before any network call, so a bot costs nothing:
method → Origin/Referer → honeypot → email validation → Turnstile → Buttondown
The honeypot is hardened against autofill — off-screen, out of the tab order and the
accessibility tree, autocomplete="off", named to match no autofill category. A
password manager filling it would silently drop a real subscriber, which is the same
optimistic-success bug this build exists to delete. Tripping it returns the ordinary
success response and creates nothing, so a bot learns nothing from trying. Turnstile
is verified server-side and fails closed; the widget alone protects nothing, since
any string can be POSTed to a public endpoint. x-forwarded-for is dropped as
untrusted, because honouring a client-supplied header would let a bot hand a
clean-looking IP to both Turnstile and Buttondown.
Deliberately not built
A signed timestamp or dwell check was designed and then cut: the form is statically rendered, so an embedded timestamp is identical for every visitor and stays valid for the life of the deploy. Scrape once, replay forever. Turnstile already covers that ground, and cutting it removed a rendering-mode change and an environment variable too. Rate limiting is deferred — it needs durable state, and the traffic shape above suggests it would earn very little here anyway.
The breaking change I took knowingly
Subscribing now requires JavaScript. A token-less submission is rejected, because
accepting one is the bypass — it would void the entire control. The <noscript>
note says so before someone types an address, not after. That’s a real accessibility
cost, taken deliberately rather than discovered later, and it’s the kind of trade a
bot-mitigation control almost always forces.
The test
Thirteen acceptance checks, written into the brief before implementation rather than retrofitted afterwards.
- 12
- passed
- 1
- skipped, deliberately
- 0
- failed
They were run on a Deploy Preview and then re-run against production, because the real Turnstile key pair is a configuration a preview can’t exercise — and a test secret left live in production would leave the endpoint wide open while appearing to work. That’s check #12, and it’s the one I’d least want to have skipped.
The skip is #13, the Turnstile replay path. It differs from the rejection test by one
error string, exercises Cloudflare’s behaviour more than ours, and each flip costs a
redeploy cycle. Recorded as skipped rather than quietly dropped. One further control —
the forged x-forwarded-for header — is correct in code but not directly
observable from outside, since the only external evidence would be the IP recorded
on a created subscriber.
Local testing was not a valid surface at any point: npm run dev and npm run build
don’t serve Netlify Functions at all, so every server-side control here was
unverifiable until a preview existed. Every attempt returns the site’s 404 page, which
the client reports as a generic error — the exact trap that made the previous fix ship
broken.
The figures that didn’t survive
This section exists because I nearly published four numbers I couldn’t source. When I came to write this page, the traffic figures in my build notes — “~12 over four days, then ~25 over three,” “33% bounce” — turned out to be recollection that had been repeated often enough to read as measurement. Pulling the actual export:
| Written from memory | What the export shows |
|---|---|
| ~25 signups over three days | 33 over 64.9 hours — I’d undercounted |
| 33% hard-bounce rate | 3 of 7 emailed (43%), or 9% of all signups. The original quoted no denominator, so it wasn’t wrong so much as unfalsifiable |
| ~12 signups in an earlier wave | No trace in the export. Retracted |
| A specific spam domain in the traffic | Not in the export either, though it remains in both blocklists |
The same pass caught one more. My notes said the role-address filter was “validated
against the observed traffic, which included accountspayable@, support@,
thbusinessoffice@.” Running the shipped list over the real export: it catches three
of those four. The matcher compares the local part exactly, and thbusinessoffice
isn’t in it. So that control would have stopped 3 of 33 signups on its own. It’s a
cheap pre-filter that saves a network call, not a defence — Turnstile does the actual
work, and I’d described a supporting actor as the lead. [my setup], all of it.
That’s the same failure as the three green-result failures above, wearing different clothes: a claim that was never checked against the thing it described. The rule that fixes both is identical — derive it where the data actually lives, or don’t publish it.
- First commit to merge
- 2h21m
- Commits on the branch
- 4
- Production deploy (15 credits)
- 1
A footnote on those credits — this is a Netlify bill, not a Buttondown one. The site is hosted on Netlify, which meters production deploys at 15 credits each against a 300-credit monthly allowance. It does not meter traffic in any way that mattered here: July served nine thousand web requests for 2.3 credits, while deploys took 315 of 317. The allowance ran out before the month did, mostly on this endpoint’s
diagnose → fix → diagnose → fixcycle — every push tomaintriggered a billed deploy, so debugging a server-side function was the single most expensive thing the project did.The fix was a $9 top-up and a switch to branch-per-build: Deploy Previews and branch deploys cost 0, and production is paid for exactly once, at merge. This build ran entirely on free previews and spent a single production deploy. The debugging didn’t get cheaper — it moved somewhere it wasn’t billed.
Reproduce this
The pack is partial and says so — this build’s evidence exists, the rest of the walkthrough doesn’t yet. Listing what’s absent rather than promising it is a rule this project learned the hard way.
What’s in the repository now:
repro/spam-wave-masked.csv— every signup in the wave, with timestamps, statuses, domain class and /24, from which every number on this page can be recomputed.repro/README.md— the masking rule column by column, and an explicit list of what the data does not substantiate.repro/spam-wave-masked-full.png— all 34 rows, of which the image above is the top twelve.build-notes.md— the journal, dead ends included, with the corrected figures marked as corrections.test.md— all thirteen checks, the skip, and every failure tagged.
The raw subscriber export is deliberately not published, in any repository, public or private. Its only job is to let someone re-derive these numbers, and nobody re-derives from a repository they can’t clone — so the masked derivative is the artifact that actually substantiates anything. The raw file stays offline.