Why Spam Beat My Rate-Limited Contact Form

Why does spam get through a rate-limited contact form? Because a rate limit counts requests per address, and nothing in it asks who sent them. On October 30 and November 2, two vulnerability scanners crawled my site, loaded the home page that carries the form, the way any visitor does, picked up a valid CSRF token, and posted the form until they moved on or were blocked. On the first day 101 submissions went through and on the second 31, each one a 200 from the app, which it returns for a send and also for a failed one, so those numbers are the most email they could have produced.12 My app’s limiter, set to five posts a minute per address, refused 830 and 31 of their posts and let 101 and 31 sends through, 47 and 9 more than an exact limiter at that setting would have allowed.3 The edge rate limit never fired on either scanner: I created it about 12 minutes after the first flood’s last form post, and the second scanner’s posts arrived through two Cloudflare data centers, which keep separate counters. That split is my strongest explanation for the second silence: it fits the minute totals, which cannot prove it.456 On my studio site, a honeypot field caught 42 of 52 submissions in eight days and let through the 10 whose senders left the honeypot empty.78 This site’s edge did have a rule that can ask whether a person is there, a managed challenge on the form’s path, but it fires only on a listed user-agent string, and both scanners sent a browser’s string; a second challenge, keyed on a high threat score, could never fire, because Cloudflare now sets that score to zero, so nothing asked every submission.910411 Since the morning of November 3, Turnstile asks it of every submission that could reach my inbox, on both forms.12

TL;DR

  • Two scanners, one inbox. One address sent my site 76,429 requests across 53 one-minute buckets on October 30; another sent 10,907 across 22 on November 2. Both crawled the whole site, both sent SQL-injection strings in paths and in the User-Agent header, and both posted the contact form: 1,065 times on the first day and 500 on the second.131412
  • A valid CSRF token proves only that a client held a token my site issued. Every send on both days carried one, because my tokens are stateless and any client that fetches the page gets one. The CSRF check refused four posts in two floods.1516
  • My own limiter leaked. It reads a counter from Cloudflare KV, compares it, then reads it again and writes the count plus one in a separate request; a failed second read writes one, and a failed KV call falls back to a count kept in one process’s memory. Any of those paths can pass more than five, and the logs cannot say which one did: 18 sends went out in one minute against a limit of five.171
  • The edge rate limit counts per address per data center. On November 2 the scanner’s 85 form posts from 18:56 to 19:05:00 UTC, the minutes on either side of the block, came through Amsterdam and London, 44 and 41, and the busiest minute split 18 to 14 between the two. That split is the likeliest reason the rule stayed quiet, though minute totals cannot prove it. A probe I ran the next morning proved that the rule fires and that Cloudflare logs it.518
  • The blocks were address blocks, and both came too late to keep the email out. On October 30 I blocked the address by hand 3.9 minutes after I noticed, about 50 minutes after its first form post. On November 2 my monitor blocked it 8 minutes after the scan’s first minute. No send on either day came after the block minute: on October 30 the last came more than 12 minutes before the block, and on November 2, 26 came in the minutes before it and 5 in the block’s own minute, which per-minute data cannot place before or after the block’s second.101912
  • The honeypot catches bots that fill every field. On my studio site it answered 42 of 52 posts as if they had succeeded and stored nothing; by the hour they arrived, every submission from a Tor exit was among them. The 10 that were stored, and so at most 10 emails, came from senders that left the honeypot empty.720
  • What changed. Both forms now carry a Cloudflare Turnstile widget in managed mode, and the server stores or sends nothing until Cloudflare’s siteverify API accepts the submission’s token, refusing a missing, oversized, or unverifiable one. Turnstile runs challenges in the visitor’s browser and processes signals such as IP address, TLS fingerprint, and user agent; Cloudflare says it does not touch form entries. It does not stop a person typing spam by hand, and it does not replace the rate limits, which still bound volume.122122

1. The question: “is that you?”

On November 2 my inbox filled with contact-form messages, and my first question, put to the agent session that runs my infrastructure, was whether my own automation was sending them. It was not. A scanner was, through the contact form on this site, and the form on my studio’s site, 941apps.com, was taking a slow drip of its own, though none of it reached the inbox that day.238 All three mail streams, my monitor’s alerts and the two forms, send from one address on my own mail domain, so in the inbox they looked alike.23

The subjects gave the game away. This site’s contact handler sends each submission to me with the subject “Contact Form:” followed by whatever the sender typed in the name fields, so a scanner’s payload becomes a subject line.2423 The studio site’s form stores each message in a database and then mails it to the same inbox.25

The question I asked first was the wrong one, and the right one took a day of queries to answer: which of the controls on these forms could have told a scanner from a person, and why did each one let the rest through? There were five kinds of control. On this site, an edge firewall with custom rules, an edge rate limit, a CSRF token, and a rate limiter in the app. On the studio site, the same kind of edge rules, an edge rate limit and an app limiter of its own that refused none of the observed form posts, and a honeypot field.9257 The rest of this post takes the two floods and the slow drip one at a time, then the five layers one at a time, with the rule text or the code’s behavior for each.

Where the numbers come from

Every count below comes from Cloudflare’s request analytics and firewall events for the two zones, queried on November 3 over windows that had closed, plus the studio site’s message table and my monitor’s own log.16 A script turned those saved query results into a ledger of a few hundred rows, each with the rule that produced it, and the post takes its numbers from the ledger and its addendum and nowhere else. Where my notes from the day disagree with the ledger, the ledger wins: I wrote the October 30 notes minutes after the flood, while Cloudflare was still ingesting its last minutes, and they undercount.1626 Cloudflare keeps these datasets for “at least 31 days of data on every plan”, so I could count a flood after the fact, but not forever.27

Two kinds of number need a warning. Firewall events are a sample, so I use them for which rule acted and when, and the request datasets for how many.16 And a form post that answered 200 on this site is a send, or a failure the handler also answers 200 (it has two such failure paths), not a delivered email, and I did not check my mail provider’s log. Every email count in this post is therefore a ceiling.2428

What normal looked like

Before the floods, the form was quiet. In the eight days the queries cover, three addresses other than the two scanners posted the form successfully, each exactly twice, one address a day on October 27, 28, and 29.29 I do not know whether those were people. Two posts from one address fit a double-click, and they fit a bot that retries. That uncertainty is the whole problem in miniature: nothing in the logs or the controls could say.

2. Flood one: October 30, blocked by hand

The first flood came from one address on a network that the regional internet registry lists under BULLIONET FBW NETWORKS SAS, registered in France.30 Its first request landed in the 17:59 UTC minute, 10:59 Pacific daylight time, and its last in the 18:51 minute, 11:51 PDT: 53 one-minute buckets and 76,429 requests to this site. Two independent queries, one per minute for the address and one sweeping every zone in my account, agree on that total, and the address sent nothing before or after.13

The shape of the scan

The form was a small part of it. Before the block, the address averaged 1,389.7 requests a minute and peaked at 3,219 in the 18:36 minute, and it walked the whole site: at least 2,881 distinct paths, a lower bound because the path query hit its row limit.1331 Over the whole scan the outcomes split like this:13

Outcome Requests Share
Served (2xx, 3xx) 49,513 64.8%
Refused by the app (4xx), including 10,019 from the site’s agent gate 18,990 24.8%
Origin errors (520) 2,339 3.1%
Blocked at the edge (403) 5,587 7.3%

The edge’s 7.3% is almost all the block: 5,234 of its 5,587 refusals came after the block minute.13 For the fifty minutes before it, only the exploit-path rule acted. That rule worked from the first minute, with the first firewall event at 17:59:53 UTC, but it matches path substrings like .php and /wp-, and most of this scan asked for real pages.109

What made it a scan rather than a crawl was what it put in the requests. Nearly all of the traffic, at least 69,322 requests, used one plain Chrome 141 user agent for Windows. At least 2,755 more claimed to be Googlebot, and the edge’s verified-bot rule did not treat them as Google.32 At least 3,154 requests carried an SQL-injection payload in the User-Agent header itself, in at least 396 distinct strings, and at least 2,680 requests carried one in the path, across at least 810 distinct paths.3231 The payloads belong to documented families. PG_SLEEP(15), waitfor delay '0:0:15', and XOR(if(now()=sysdate(),sleep(15),0)) are time-based probes: PortSwigger’s cheat sheet lists the delay syntax for Oracle, Microsoft SQL Server, PostgreSQL, and MySQL, and sqlmap’s own payload definitions contain the same functions.3334 PortSwigger states the reason they work: “As SQL queries are normally processed synchronously by the application, delaying the execution of a SQL query also delays the HTTP response.”35 One slow response proves little; a delay of about 15 seconds that comes back each time the payload asks for it, and not on control requests without it, is evidence of a possible injection.

The paint app on this site has a share endpoint, and the scanner found it. It sent 320 POSTs to the share path, the only address to post there in the eight days the queries cover, and the endpoint refused all 320 and stored nothing. Around them it posted to variants of the path with the payload built in, such as /paint/share'||DBMS_PIPE.RECEIVE_MESSAGE(CHR(98)||CHR(98)||CHR(98),15)||' and /paint/(select 198766*667891 from DUAL), which the site answered 404.31 The address also touched one other zone in my account, sending 387 GETs to kiradex.app, all answered 200, and no others.13 My reading is that it followed links from this site to that one; I have not checked the links.

OWASP’s catalog of automated threats has a name for the activity: OAT-014, Vulnerability Scanning, “Crawl and fuzz application to identify weaknesses and possible vulnerabilities.”36 The contact form was one more input to fuzz.

The form, minute by minute

The address posted the contact form 1,065 times, and every post reached the app; the edge stopped none of them.1 The first post and the first send came in the 18:00 UTC minute, 11:00 PDT. Here is every minute in which a form post reached the app, by the status the app returned:1

UTC PDT Sent (200) CSRF refused (403) Missing field (422) Limiter refused (429)
18:00 11:00 8
18:01 11:01 12 133
18:02 11:02 9 148
18:03 11:03 4 4 200
18:04 11:04 9 89
18:05 11:05 16 164
18:06 11:06 18 85
18:07 11:07 7 11
18:08 11:08 2
18:09 11:09 4
18:10 11:10 9
18:11 11:11 1
18:12 11:12 1
18:35 11:35 1
18:36 11:36 1
18:37 11:37 129

The totals: 101 sent, 830 refused by the app’s limiter, 130 refused because a required field was missing, and 4 refused by the CSRF check, all four in the 18:03 minute.1 The 429s were the app’s. The rate-limit rule at the edge did not exist yet, no rate-limit firewall event exists for the address, and where Cloudflare recorded the origin’s status for these posts, the 429 came from the origin.1037 The 422s came from FastAPI’s form validation, which runs before the limiter, so they never used up a limiter slot; I checked the order with a ten-line FastAPI app, where a post missing a required field returned 422 without ever calling the wrapper.38 The burst of 129 at 18:37 looks like the scanner fuzzing the form’s fields by dropping them.

Read the “Sent” column against the limiter. The limiter allows five posts per address per clock minute, and in eight of those minutes more than five went out, 18 of them at 18:06.13 Section 4 explains how.

The notice and the block

I noticed at 11:46 PDT and wrote down that something was hitting the site’s contact form over and over.26 By then the first form post was about 46 minutes old, and the form flood itself had ended at 11:37 PDT.1 I added the address by hand to the account-level blocklist that the zone’s first custom rule already matches, and the first event from that rule came at 18:49:53 UTC, 11:49:53 PDT.10 The block came 3.9 minutes after I noticed, about 50 minutes after the first form post, and 50.9 minutes after the scan’s first minute.113 All 101 sends had gone out before it; the last form post to reach the app came about 12 minutes before the block.1 After the block minute the address sent 5,234 more requests, and the edge refused every one.13

In the same second, 18:49:53 UTC, I created the zone’s one rate-limiting rule: form POSTs to the contact and paint share paths, 3 per 10 seconds per address, blocking for 10 seconds.9 At 18:52:38 UTC I tested it from my own machine. In my notes from that test, six posts a second apart with a browser user agent and no valid CSRF token “returned 403 403 403 429 429 429 (the app’s CSRF rejection, then the edge’s rate limit)”, and plain curl posts never reached the rule at all, because an existing custom rule served them a managed challenge instead.26 My notes say the posts carried no token, but a post missing that field draws a 422 before the handler runs, as my probe on November 3 showed, so the 403s mean a token that was present and not valid.18 The firewall events agree: three rate-limit events from my test address, the first at that second, and 24 managed challenges for my curl posts.39 None of my test traffic is in any count above.

My monitor was not running that day. I re-armed it on October 31, and its traffic detector came after this flood.19 The October 30 block was a person reading the inbox.

Bar chart, one bar per minute from 10:59 to 11:51 Pacific time on October 30, 2026, of every request one scanner address sent blakecrosley.com (76,429 in all), split by who answered. Before the block the bars average 1,390 requests a minute (peak 3,219 at 11:36 PDT), mostly served pages, with app refusals and some origin errors; the contact-form flood (1,065 posts, 101 of them answered 200, a send or a failure the handler also answers 200, so at most 101 emails) falls in the 38 minutes from 11:00 to 11:37 PDT. A dotted line marks when the author noticed, 11:46 PDT, and a dashed line the block he added by hand, in effect at 11:49:53 PDT (18:49:53 UTC), 51 minutes after the first minute of the scan. Every request in the subsequent minute buckets was blocked at the edge, through the last minute with a request, 11:51 PDT.

October 30: 76,429 requests from one address, per minute; until I blocked the address by hand, the edge refused almost none of them.1310

3. Flood two: November 2, blocked by my monitor

The second address belongs to NovoServe B.V., a hosting provider in the Netherlands.40 It sent two GETs at 07:49 UTC, 23:49 Pacific standard time on November 1, then nothing for 11.1 hours. The scan proper ran from the 18:56 UTC minute to the 19:17 minute, 10:56 to 11:17 PST: 22 one-minute buckets and 10,907 requests, or 10,909 with the two early GETs. Four independent queries agree on the total.14

It looked like the same tool. It used the same Chrome 141 Windows user agent string as the October 30 address for 9,725 requests, the same Googlebot string for 942, the same injection families, and it touched the same two zones and no others, with 368 GETs to kiradex.app.414214 Two addresses on two networks in two countries prove nothing about who ran them, so I leave it at “looked like”.

The timeline, in both time zones

UTC PST What happened
18:56 10:56 The scan begins: 29 requests in the minute.1419
18:57:00 10:57:00 A run of my monitor starts. Its window closes at 18:57:22, so it sees about a minute of the scan, and it finds nothing.19
18:57 10:57 The busiest minute before the block, 637 requests. The edge’s exploit-path rule starts blocking at 18:57:11.144
18:58 10:58 The first three form posts. The app sends all three.2
19:00 11:00 Four more form posts, all four sent.2
19:02 11:02 32 form posts reach the app: 9 sent, 23 refused by the app’s limiter.2
19:02:46 11:02:46 The next monitor run starts.19
19:03 11:03 10 sent, 8 refused.2
19:03:53 11:03:53 My monitor raises a critical request-flood finding: 1,914 requests from the address.19
19:04:01 11:04:01 My monitor adds the address to the account blocklist.19
19:04:03 11:04:03 The first event from the blocklist rule.4
19:04 11:04 The last five sends and 22 form posts refused at the edge, all in the block’s own minute; the minute bucket does not say which came first.2
19:07 11:07 The busiest minute of the scan, 1,107 requests, every one blocked.14
19:17 11:17 The last minute, 300 requests, every one blocked.14

My monitor’s eight minutes

My monitor raised its first finding 7.9 minutes after the scan’s first minute and the block took effect 8.0 minutes after it, or 6.8 minutes after the first firewall event.194 By then 26 sends had gone out in the minutes before the block, and the last 5 fell in the block’s own minute, which the per-minute data cannot place before or after the block’s second; none came in subsequent minutes.2 Eight minutes is quick for a block and slow for an inbox.

The run that started at 10:57:00 PST had the scan in view for about a minute, and in its window the address made somewhere between 29 and 666 requests; the upper figure counts the whole 18:57 minute, of which the window saw only 22 seconds. The detector logged no finding.19 The next run, at 11:02:46 PST, found the flood and blocked it, and that run shows why the first could miss. Its window ended the moment it asked Cloudflare, before the newest events had arrived: its log records 1,914 requests from the address, where my replay of the same five-minute window on November 3, over complete data, counts 2,143; the replay is saved with its commands and full output, and its counts are rows in the ledger’s addendum.1943 The replay also raises a form-flood finding the live run never raised, from 39 posts, 23 of them refused, at high severity, which would have been a line in the daily digest and not an email or a block.43 I replayed the second run’s window, not the first’s, so for the 10:57 PST run the same lag is the likely cause and not a measured one. The same day I changed the monitor’s windows to end 90 seconds before each run and to start where the previous one ended.43 Six findings fired on the address in all, and my monitor folded every one into its daily digest instead of emailing me, because the address was already on its block ledger.19 How my monitor should alert, and this scanner seen from its side, is the subject of a separate post; for this one, what matters is that a block is a reaction, and the reaction came after at least 26 of the 31 sends.

The form, by status

The address posted the form 500 times.2

UTC PST Sent (200) Limiter refused (429) Edge blocked (403)
18:58 10:58 3
19:00 11:00 4
19:02 11:02 9 23
19:03 11:03 10 8
19:04 11:04 5 22
19:05 11:05 39
19:06 11:06 19
19:07 11:07 10
19:08 11:08 13
19:09 11:09 31
19:10 11:10 41
19:11 11:11 47
19:12 11:12 97
19:13 11:13 29
19:14 11:14 23
19:15 11:15 21
19:16 11:16 28
19:17 11:17 18

The totals: 31 sent, 31 refused by the app’s limiter, and 438 refused at the edge, all at or after the block minute. So 62 posts reached the app.2 Each send proves the scanner held a valid CSRF token, because the handler checks the token before it sends. The 429s prove nothing either way, because the limiter refuses a post before the handler reads its token.2416 Where Cloudflare recorded the origin’s status for those 429s, it was the origin’s, the app limiter’s, and no rate-limit firewall event exists for the address.374

The rest of the scan

By outcome, the November 2 scan split like this:14

Outcome Requests Share
Served (2xx, 3xx) 1,388 12.7%
Refused by the app (4xx), including 420 from the agent gate 1,473 13.5%
Origin errors (520) 116 1.1%
Blocked at the edge (403) 7,930 72.7%

The edge’s 72.7% is mostly the block: 2,986 requests came before the block minute and 7,445 after it, and the site served none of the later ones.14 The path query for this day was complete: 2,173 distinct paths, and 778 requests on 542 of them carried an SQL-injection payload.44 The user-agent query was complete too, 73 distinct strings. Besides the plain Chrome string and the Googlebot claim, the scanner sent 54 distinct SQL payloads in 149 requests, three quote-breaking strings in 78, and 12 marker tokens, each sent once.41

Some of those strings are worth looking at, because they show what the scanner was asking. (select 198766*667891 from DUAL) is an arithmetic expression inside a table-less select; FROM dual is Oracle’s syntax for one, as PortSwigger’s cheat sheet shows.33 if(now()=sysdate(),sleep(15),0) has the same IF(condition, SLEEP(n), x) shape as the conditional time delay on the same cheat sheet.33 @@qkLpP is an @@ prefix, the one the cheat sheet uses in SELECT @@version for Microsoft and MySQL, followed by five characters, and the scanner sent a dozen like it.3341 OWASP’s testing guide names the two techniques these strings serve: error-based injection, which “forces the database to generate an error”, and time delays that “use database commands (e.g. sleep) to delay answers in conditional queries.”45 None of the pages I fetched documents these exact strings, so I describe them and do not attribute them to a particular tool.

Bar chart, one bar per minute from 10:56 to 11:17 Pacific time on November 2, 2026, of every request one scanner address sent blakecrosley.com (10,907 in all), split by who answered. Before the block the bars are mostly served pages and app refusals, peaking at 637 requests in a minute; 62 of the address's contact-form posts reached the app, and 31 of those answered 200, a send or a failure the handler also answers 200, so at most 31 emails: 26 in the minutes before the block minute and 5 in the block minute itself, none in subsequent minutes. A dashed line marks the block at 11:04:01 PST (19:04:01 UTC), 8 minutes after the first minute of the scan, placed by the monitor; thin dotted lines mark the monitor run at 10:57:00 PST that saw only the scan's first moments and found nothing, and the run at 11:02:46 PST that raised the flood finding. Every request in the subsequent minute buckets (7,445 in all) was blocked at the edge, peaking at 1,107 in a minute.

November 2: 10,907 requests from one address, per minute, with my monitor’s two runs and the block it placed at 11:04:01 PST.1419

4. The five layers, and what each one could tell

Five kinds of control stood between these bots and my inbox. For each one I asked the same question: could it tell a bot from a person? One managed challenge in the edge rules can put that question to a visitor, but only to requests whose user-agent string is on its list, and the scanners’ were not; a second, keyed on a threat score, could never fire, so nothing asked it of every submission. Two of the controls count, one matches names and lists, one proves that a client holds a token the site issued, and one catches only the bots that fill a hidden field. Here is each one, what it checks, what it stopped, and the exact reason it let the rest through.

Edge custom rules

What they check. On this site, the first custom rule blocks any address on an account-level list, plus seven hosting networks by ASN. Another blocks exploit-path substrings such as .php, /wp-, and /.env. Two more serve a managed challenge on the contact form and the admin path: one for an empty user agent or one containing curl, wget, python-requests, httpx, scrapy, HeadlessChrome, PhantomJS, or Selenium, and one for a high Cloudflare threat score. The studio site has the same kinds of rule, with both challenges applied site-wide and a block on API paths that exempts the contact form.9 The threat-score challenges, one on each site, were dead rules: Cloudflare’s field reference says the score once ran from 0 to 100, and “Now, the threat score is always 0 (zero).” A rule that asks for a high score matches nothing, and I disabled both on November 3, the day I read that; the user-agent challenges stay.1146 Cloudflare’s Free plan allows five custom rules, which “work as customized web application firewall (WAF) rules that you can use to perform actions like Block or Managed Challenge on incoming requests.”47

What they stopped. The exploit paths, from the first minute on both days.104 On October 30, no form post at all.110 On November 2, the 438 form posts after my monitor’s block.2 On the studio site, no form post at all: the firewall logged no event on a POST to the form in the eight days.7

Why the rest passed. No rule looks at what a request is. The list rule matches an address only after something puts it on the list, a person on October 30 and my monitor on November 2.91019 The user-agent challenge matches a string, and both scanners sent a browser string, at least 69,322 times on the first day and 9,725 times on the second.3241 The user-agent challenge does work against tools that announce themselves: my own curl test on October 30 drew 24 managed challenges.39 The threat-score challenge could not fire on anyone, and the firewall logged no challenge on the form for either address.11104 Tor exits and hosting networks outside the seven named ASNs are on no list.

An edge rule can tell a known address, a known network, or an honest tool from the rest, and its challenge asks whether a person is there only of the requests its expression selects; it cannot tell a browser string sent by a scanner from a browser.

Edge rate limit

What it checks. The rule text is short:9

(http.request.method eq "POST" and http.request.uri.path in {"/api/contact" "/paint/share"})

It counts matching requests by Cloudflare data center and client address, allows 3 per 10 seconds, and blocks for 10 seconds. Spread evenly, that ceiling is the nominal equivalent of 18 a minute per address per data center; the rule itself counts in 10-second periods.916 The Free plan fixes the period, the timeout, and the counting key, and gives the zone one such rule: a 10-second counting period, a 10-second mitigation timeout, and counting by IP.48 The data center is not optional either. “Every rate limiting rule includes the Cloudflare data center ID (cf.colo.id) as a mandatory characteristic,” Cloudflare’s documentation says, and “Counters are not shared across data centers, with the exception of data centers associated with the same geographical location.”6

What it stopped. No scanner request on either day. Its only events in the incident window were my own test’s three.10439 The studio site has a site-wide rate-limiting rule of its own, counted the same way over every request except those from clients Cloudflare lists as verified bots, and it refused none of the drip’s form posts: its firewall logged no event on a post to the form in the eight days. That rule counts every such request an address sends the site, and my data counts only form posts, so it does not measure how much room the counter had left.97

Why the rest passed. On October 30, the rule did not exist until 18:49:53 UTC, the same second as the first event from the hand block and about 12 minutes after the last form post reached the app.91 That scanner’s 1,065 posts all came through one data center, Paris, so a rule in place would have kept one counter for them.5 On November 2 the rule existed and the scanner’s form posts came through two data centers, Amsterdam with 44 and London with 41, from 18:56 to 19:05:00 UTC. In the busiest minute, 19:02, London carried 18 and Amsterdam 14.5 Eighteen in a minute is the nominal equivalent of the rule’s ceiling, but my data holds minute totals, not arrival times, so it cannot show whether any 10-second period at one data center held more than three. Cloudflare is plain about precision: “Rate limiting rules are not designed to allow a precise number of requests to reach your origin server. There may be a delay of up to a few seconds between detecting a request and updating rate counters.”48 Over the seven-minute span from its first post to reach the app to its last, the scanner averaged 8.86 a minute.2 My notes from the day called that “about nine a minute” and stopped there; the split across data centers is the part they missed.23

I wanted proof that the rule works at all, so on the morning of November 3 I probed it from my own machine. At 06:36:49 PST I sent ten POSTs 0.4 seconds apart without a CSRF token: four came back 422, because a required field was missing and FastAPI refused them, then six came back 429. At 06:38:03 PST I sent eight with every field present: three came back 403, the CSRF refusal from the origin, then five came back 429 with the body “error code: 1015” from Cloudflare. The firewall logged 11 events on the form in those minutes, all of them blocks from the rate-limiting rule.18 The rule fires, and Cloudflare logs it. My reading of November 2 is a hypothesis: split across two counters and running near the nominal ceiling on each, the scanner never tripped either one. It is the strongest explanation that fits the minute totals, not a demonstration; the totals cannot show how the posts were spaced inside each minute, and Cloudflare allows that excess requests can pass while its counters update.548

A rate limit can tell fast from slow, for one address at one data center; it cannot tell who is sending.

CSRF token

What it checks. This site’s form carries a hidden token, rendered into the page, with three parts: a timestamp, a random value, and an HMAC signature over the first two. The server accepts it if the signature matches and the timestamp is under an hour old.15 The studio site’s form has no CSRF token.25

What it stopped. Four posts on October 30, all in the 18:03 minute, and none on November 2.12

Why the rest passed. The token is stateless. It is not bound to a session, a cookie, or an address, so any client that fetches the page gets a valid one, and any client handed a token by another can present it while it lasts. Both scanners did fetch the site’s pages; that is what the request logs show. What the check shows is narrower: every send on both days carried a valid, unexpired token my site had issued, because the handler checks the token before it sends.1513142416 A CSRF token exists to stop another site from making a visitor’s browser submit my form. It was never meant to stop a client that wants to submit the form itself, and it did not.

The same check had a second flaw, and it fell on people rather than bots. When the token failed, the handler raised a JSON 403 that the HTMX form never displayed, so a person with an expired token would have clicked Send and seen nothing at all.12 I fixed that one, though my first fix did not work either; section 6 says how.

A valid CSRF token proves the client held an unexpired token my site issued; it cannot prove who filled in the form, or even that this client loaded the page.

App rate limiter

What it checks. This site’s handler sits behind a decorator that counts form posts per client address per clock minute in Cloudflare KV, with an in-memory fallback, and refuses past five with a 429.17 The studio site has an in-memory limiter per process for anonymous clients, counted per address and per client classification, the class the limiter assigns from the user agent and address, over every path except health checks and static files.25

What it stopped. 830 posts on October 30 and 31 on November 2. None on the studio site.127

Why the rest passed. By design it passes five a minute to anyone, and because the window is the clock minute, a burst that straddles a minute boundary gets two allowances.17 I knew that much. What I did not know until I read the counts against the code is that it leaks, and the code has at least three ways to leak. The limiter reads the counter from KV over HTTP, compares it to the limit, and then writes the count plus one in a separate request, so two posts that both read before either writes both pass. The increment reads the counter a second time before it writes, and if that second read fails, the write sets the count back to one, so the minute starts over. And whenever the KV read returns nothing or the write fails, the decision falls back to a list kept in one process’s memory, which other processes do not share.17

The counts show the leak. On October 30, eight minutes held more than five sends, and the 18:06 minute held 18.1 An exact limiter at five a minute would have allowed at most 54 sends over the minutes the scanner posted in, and 101 went out, 47 past the ceiling. On November 2 the 19:02 and 19:03 minutes passed 9 and 10; an exact limiter would have allowed at most 22 of the 62 posts it saw, and 31 went out, 9 past the ceiling.3 They do not show which path leaked. My reading is that posts read the same count before either wrote, or that KV calls failed under the load, resetting the count or handing the decision to per-process lists, or some mix of these; the logs I have record the status the app returned, not which path decided it, so they cannot tell the paths apart.173 The remedy is the same for all of them, and it is not a one-line change: a counter store with an atomic increment, which a KV read followed by a separate write does not provide. The leak is my bug. It also makes the argument of this post sharper than I could have made it on purpose: a control that says five and passes eighteen was never going to be the thing that decides who gets to write to me.

On the studio site the app’s limiter refused none of the observed form posts, and the busiest address sent 13 in an hour. Its counters take in an address’s requests to every other path under the same client classification, and my data covers only form posts, so the counts do not measure how much room those counters had.725

A rate limiter can tell five from six when the requests take turns; it cannot tell a person from a script at any count.

Honeypot

What it checks. The studio site’s form has a text input named website that a person never sees: it carries tabindex="-1" and aria-hidden, so neither the keyboard nor a screen reader lands on it. If it comes back with anything in it, the handler answers with the same success message a person would see, and stores and mails nothing.25 The branch has been in the handler since I built the form on June 5.25 This site’s form has no honeypot.24

Ned Batchelder described the technique in 2007: “These fields are called honeypots and are validated when the form data is posted. If they contain any text, then the submitter must be a bot”.49 His reason it works is that form-filling bots “cannot distinguish invisible fields from visible ones.”49

What it stopped. 42 of the 52 posts in the eight days, 80.8%, including every post from a Tor exit by the hour they arrived and, by the hour and the message pattern, all 13 from one burst of a self-described SQL spider.720

Why the rest passed. A bot that fills only the visible fields never touches website, and the senders whose posts were stored, and so could reach my inbox, left it empty.825 That they chose the visible fields on purpose is my reading; the handler records only that the hidden one came back empty. Batchelder saw it coming: “In theory, it is possible for a spambot to defeat any of these measures. But in practice, bots are very stupid”.49 Some are less stupid than others, and the ones selling lead generation are the least stupid.

A honeypot proves only that the client filled a field a person cannot see; an empty one proves nothing.

Horizontal bar chart in three groups, one per stream, each bar a share of the submissions that reached the edge. October 30 scanner on blakecrosley.com: 1,065 reached the edge, all 1,065 reached the app, 101 answered 200, a send or a failure the handler also answers 200, so at most 101 reached the inbox. November 2 scanner: 500 reached the edge, 62 reached the app, none after the block minute, 31 answered 200 (26 in the minutes before the block minute and 5 in the block minute itself), so at most 31 reached the inbox. 941apps.com over eight days: 52 reached the edge, all 52 reached the app, 10 were stored and so at most 10 were mailed; the honeypot answered the other 42 as if they had succeeded and stored nothing.

How far each stream got: the two scanners’ posts on this site, and eight days of the drip on the studio site.1278

What the five had in common

Read the five sentences together and the gap is plain. Rate limits bound volume per address. A CSRF token proves a client held a token the site issued. A honeypot proves the client filled a hidden field. The edge rules know names and lists, and the one that could ask whether a person is at the other end, the user-agent challenge, asks only the requests whose user-agent string is on its list; the threat-score challenge beside it could never fire. Neither scanner matched, so nothing asked every submission, and the scanners did nothing a browser cannot do: they fetched the page, kept its token, and posted at a rate the limits let through in part. OWASP’s credential-stuffing guidance makes the same point about a different form, advising defenses that do “not rely on a single predictable volume limit”.50

5. The slow drip on the studio site

The studio site’s form never had a flood. It had a drip, and the drip is a different animal. The window here runs from the first hour with a form post in the eight-day query, 17:00 UTC on October 26, to the moment I saved the data, 13:59:50 UTC on November 3; the first and last days are partial.7

Posts against stored rows

The form took 52 POSTs in that window and answered every one with 200. The message table gained 10 rows over the same window. Since the honeypot branch is the only path in the handler that answers 200 and stores nothing, the honeypot caught the other 42, or 80.8%.7825 By UTC day:78

Date (UTC) Posts (200) Stored rows Caught by the honeypot
Oct 26 (partial) 4 2 2
Oct 27 5 0 5
Oct 28 3 2 1
Oct 29 5 1 4
Oct 30 16 1 15
Oct 31 8 2 6
Nov 1 4 0 4
Nov 2 5 0 5
Nov 3 (partial) 2 2 0

October 30’s spike is one address in Britain that sent 13 posts in a single hour with the user agent Mozilla/5.0 (compatible; SQLSpider/1.5). By the hour and the message pattern, the table stored none of them: the one row stored in that hour is a lead-generation pitch, the pattern of the American address that also posted in that hour.7820

Who was sending

Cloudflare tags requests from the Tor network with a special country code, T1. Its header reference says “T1 - Used for clients using the Tor network”, and its IP Access rules page says “T1: Tor exit nodes (country name: Tor)”.5152 By that code, 23 of the 52 posts came from Tor, 44.2%, and 23 of the 35 distinct addresses were Tor exits, 65.7%.7 As a cross-check, I compared every address that posted the form against the Tor Project’s bulk exit list, which the project calls “a nearly real time authoritative source for Tor exits that allow contacting your server on port 80”. All 23 addresses Cloudflare coded T1 were on the list I fetched on November 3, and none of the other 12 was.5354 The two sources agree. That list is the exits of November 3, though, so it confirms the code for that day and does not prove which addresses were exits on the day each post arrived. The project’s CollecTor archive keeps past exit lists and would settle event-day membership; I did not query it.55

Most addresses in this window posted once: 32 of the 35, 91.4%, and every Tor exit among them.7 The three that posted more than once were the SQL spider with 13, an address in Poland with two in one hour, and an address in the United States with five. That last one’s five posts fall in the same five hours as the five lead-generation pitches the table stored in the window, and it changed its user agent every time: Chrome 135 on Linux, then Firefox 128, 137, 138, and 137 again.720

The user agents split along the same line. Of the Tor posts, 18 named an Android device and 5 a desktop Chrome, and 21 of the 23 named Chrome 81 or older. The non-Tor posts named desktop browsers far newer than that, 10 desktop Chrome from 125 to 135 and 6 Firefox from 128 to 138, plus the spider’s 13 under its own name.7

Who got stored

The two halves met at the honeypot. No stored row in the window sits in an hour that had only Tor posts, and all 10 sit in an hour with a non-Tor post. 22 of the 23 Tor posts fell in hours with no stored row at all, and the 23rd shares its hour with the American lead-generation address.20 I matched by the hour because the request logs and the message table share no request id, so the match is an inference: by the hour they arrived, every Tor submission in the window was a honeypot trip.20 What was stored, and so could reach the inbox, came from addresses outside Tor, sending newer desktop browser strings and leaving the honeypot empty.

The numbers fit the old pattern of form spam. Batchelder’s 2007 taxonomy divides spam creators into “Playback spambots, form-filling spambots, and humans.”49 The Tor drip behaves like form-fillers that fill every field and fall into the honeypot. The senders that got through behave like something closer to a person at a keyboard, or a script built to look like one. OWASP files the activity under OAT-017, Spamming: “Malicious or questionable information addition to public or private content, or messages.”56 OWASP also scopes one thing out: “The mass abuse of broken form-to-email and form-to-SMS functions, to send messages to unintended recipients, is not included in this threat event”.56 My forms mail only me, so they are the included case, spam addressed to the site’s owner, and not that one.

The drip also defeats rate limiting by design. OWASP’s credential-stuffing guidance names the effect of spreading requests over many addresses: “This may defeat both IP block-lists and rate limiting, as per IP request volume may remain relatively low”.50 One post per address is as low as volume gets.

What the stored messages say

The message table goes back to June 6 and held 108 rows when I read it; the newest arrived at 07:24 UTC on November 3.8 I sorted them by pattern with fixed rules, first match wins:57

Pattern Whole table October
“Hi, I wanted to know your price”, one language per row, one sender name 49 10
Vendor pitches: web leads, Instagram growth, explainer video, SEO, B2B data, a directory, design 22 4
Prize lures: jackpot, promo code, PlayStation, Lamborghini, Rolls-Royce links, one sender name 16 3
A “$50,000,000 per month” link on telegra.ph 9 9
A lead-generation pitch: “If I were a real customer, how soon would I get a call back?” 6 5
Other: two strings of gibberish, a mailing-list request, a note asking me to write elsewhere 4 1
My own test of the email notifier, September 21 1 0
A real inquiry, by my reading 1 0

A few of those deserve a closer look. The nine $50,000,000 rows came under one sender name with nine different sender addresses, each a small variation on the same headline.8 The price question appears in 25 languages over the whole table: Afrikaans, Armenian, Basque, Belarusian, Bengali, Bulgarian, Catalan, Croatian, Danish, English, Georgian, Greek, Hungarian, Icelandic, Indonesian, Irish, Italian, Latin, Latvian, Lithuanian, Luxembourgish, Spanish, Vietnamese, Welsh, and Zulu. In October alone it came in nine: Armenian, Basque, Danish, Indonesian, Latin, Latvian, Lithuanian, Spanish, and Vietnamese.57 Two of the language calls are mine and close: the sentence I read as Croatian could be Bosnian, and the one I read as Indonesian could be Malay.57 Somebody is walking a phrasebook through a contact form, one language at a time.

The rate is the drip’s other signature. October stored 32 rows, 1.03 a day, on 21 of its 31 days, and never more than three on one day.8 No per-address rate limit trips at one a day.

Which ones reached the inbox

The form has emailed the studio inbox only since September 21 at 17:46:47 UTC. Since then it has stored, and so mailed, 43 rows, an upper bound because the notify call is best effort and I have not verified the deploy time; the 65 rows stored before that date were never mailed.258 Every row in the evidence window is spam.57

Besides my own test, one row in the whole table is not. On September 14 a customer of one of the studio’s products wrote in about a specific error, in a person’s voice, and the message sat in the table with nobody reading it.57 When I wired the form to email the inbox a week later, the commit message said why in plain words: “a paying customer’s message sat unread in D1 for a week”.25 That message is the reason the notifier exists, and it is the reason I cannot fix spam by turning the notifier off. I am keeping its sender anonymous here; the point is that a real person is in the same table as the phrasebook.

6. What a bot check is, and what shipped

Turnstile, in Cloudflare’s words

Cloudflare calls Turnstile its “smart CAPTCHA alternative”, and says it “can be embedded into any website without sending traffic through Cloudflare and works without showing visitors a CAPTCHA.”21 The mechanism is the part that answers my question. “First, we run a series of small non-interactive JavaScript challenges to gather signals about the visitor or browser environment,” the overview says, and “These challenges include proof-of-work (computational puzzles), proof-of-space, probing for web APIs, and various other challenges for detecting browser-quirks and human behavior.”21 A rate limit asks how many. A CSRF token asks whether the client holds a token the site issued. Turnstile asks what kind of client is on the other end. My user-agent challenge could ask that too, but only of requests whose user-agent string was on its list, and my threat-score challenge could never fire; the difference that matters here is a challenge triggered selectively against a verification required of every submission.

The widget has three modes. I use managed, which Cloudflare describes as one that “Automatically chooses between non-interactive or checkbox challenge based on visitor risk level. No images or text to decipher.”58 In plain terms, a visitor sees a short check and nothing else unless the signals call for more, and then gets a box to tick. Invisible mode hides even that, and Cloudflare makes it conditional: a site that enables it must reference Cloudflare’s Turnstile Privacy Addendum in its own privacy policy.58 Turnstile has a free plan of its own, up to 20 widgets, with unlimited challenges, and Cloudflare says it “can be used independently without requiring other Cloudflare services.”59

The server half, which is the half that counts

The widget alone does nothing for a form, and Cloudflare says so in the first lines of its server-side guide: “You must call the Siteverify API to complete your Turnstile implementation. The client-side widget alone does not protect your forms.” And then, more bluntly: “Tokens can be forged. An attacker can submit any string to your form endpoint without completing a challenge.”60 The server posts the token to https://challenges.cloudflare.com/turnstile/v0/siteverify with its secret key, and may include the visitor’s IP address as remoteip.60

Two properties of the token shape the code around it. “Each token is valid for 300 seconds (5 minutes) after generation.” And “Tokens are single-use. Each token can only be validated once. A replayed token will be rejected with the timeout-or-duplicate error code.”60 A scanner that harvested one token could not post the form 1,065 times with it. Cloudflare also caps the token at 2,048 characters and tells the server to “Validate the action and hostname when specified.”60 One caution from the same page: it names timeout-or-duplicate for an expired token in one place and describes invalid-input-response as “Token is invalid, malformed, or expired” in its error table, so a server should treat any failure as a refusal instead of keying its logic on one code.60

What it processes, in Cloudflare’s words

Cloudflare does not claim that Turnstile uses no personal data, and I will not claim it either. Its Turnstile privacy addendum names the signals: “Cloudflare Turnstile processes a variety of client-side signals (“Signals”) such as client IP address, TLS Fingerprint, User-Agent Header and Sitekey and associated origin.” It states the purpose: “The purpose of collecting these Signals is not to identify, profile or target any individuals but solely to detect and block bots.” And it says Cloudflare also uses them to improve the product: “Cloudflare is a data controller of Signals that we process to improve Turnstile’s bot detection capabilities.”22 What Cloudflare does say it leaves alone is the content: “Turnstile does not access, store, or transmit user communications, form entries, or other page inputs.”21 The marketing page adds that “Turnstile never harvests data for ad retargeting.”61 My reading: Turnstile sees the same kind of connection data my edge already logs for every request, and never sees what a person types into my form.

The CSP it needs

Turnstile loads from Cloudflare’s challenge origin, and a Content Security Policy has to allow it. Cloudflare lists two directives: “script-src: https://challenges.cloudflare.com” and “frame-src: https://challenges.cloudflare.com”.62 Both of my zones set their policy in a Cloudflare response-header rule. On this site the frame list already existed and gained the challenge origin. The studio site had no frame-src at all, so its default-src 'self' would have blocked the widget’s iframe; it gained frame-src 'self' plus the challenge origin, and the app’s own policy in code gained the same.12

Why not a managed challenge on the form’s own POST

The obvious shortcut was already in my zone: a custom rule that answers a POST to the form with a managed challenge. My existing rules do that for curl and its relatives, and it works, because no person uses curl to send me a message.939 Extending it to every POST would have broken the form for everyone. Cloudflare documents the limitation directly: “Challenge Pages interrupt the request flow by returning a full HTML page for the user’s browser to render and solve. This mechanism fails when the browser expects a non-HTML response, such as an AJAX or XHR (fetch) request.”63 This site’s contact form is an HTMX form, posting with hx-post and swapping the reply into the page, so its POST is exactly that kind of request; the documentation does not mention HTMX by name, and the connection is my reading.24 A challenged POST would come back as an HTML challenge page, which Cloudflare marks with a cf-mitigated header “set to challenge”, and “Regardless of the requested resource-type, the content-type of a challenge will be text/html.”64 The form would swap a challenge page into its response box, or nothing.

Cloudflare’s own answer for that case is a different feature: “To ensure your API calls are protected without breaking single-page applications (SPAs) or API integrations, Cloudflare recommends using Turnstile Pre-clearance.”63 With pre-clearance, a solved widget also issues a cf_clearance cookie that “enables visitors to bypass WAF Challenges on all subsequent requests on the zone, including API requests, based on the security clearance level set by the customer.” The cookie does not replace the token: Cloudflare says it “is issued to the visitor in addition to the default Turnstile token”, and the same page still requires the server’s check: “You must call Siteverify to complete your Turnstile configuration.”65 What pre-clearance adds is an edge challenge that an XHR post can survive, for a visitor whose cookie holds a valid clearance at the challenge’s level or above. I used the default: a widget in the form and a token checked by my own server.

What shipped on November 3

On both sites I created a widget in managed mode through Cloudflare’s API, scoped to the site’s own domain. Each widget’s secret went from the API response into my Mac’s keychain and the hosting service’s variables in the same process, and never onto a command line or into a file.12

This site. A new verifier posts the token to siteverify with the visitor’s address. A missing token, a token longer than 2,048 characters, a rejected token, or a transport failure all return false, so the form fails closed whenever the secret is set; with no secret set, as in local development and the test suite, the form works as before.12 A token Cloudflare accepts must also have been served on one of this site’s own hostnames, and when Cloudflare returns the widget’s action, it must be the form’s own, as the server-side guide advises; an answer with no hostname is a refusal. So a valid token minted on another site, or by another widget, does not pass. That check went in as a second change the same morning, built from 07:20 PST; at 07:25 PST, after a cache purge, a post with a valid CSRF token from the live page and no Turnstile token came back 403 again. That proves the form still fails closed after the deploy; a token-less post cannot exercise the hostname check itself, which rests on the code and the tests.6660 Until a third change, both widgets and both verifiers also admitted localhost, and because a sitekey is public, a token minted with mine on anyone’s localhost would have passed siteverify and the hostname check alike. Cloudflare’s testing page advises against that: “Cloudflare recommends that sitekeys used in production do not allow local domains”, and it names localhost among them. At 08:12 PST I took localhost off both widgets, and both verifiers now default to the site and its www host only, so the check admits this site’s own hostnames and nothing else; local development names its hostnames in a setting. After that change both sites passed the same live checks again, at 08:15 and 08:18 Pacific: the widget on the page and a token-less post refused with a 403. No live check can exercise the hostname rule itself, since a missing or junk token fails before the hostname is read; that rule rests on the widget settings, the code, and the tests.4667 The handler’s order of checks is now CSRF, then Turnstile, then the send, behind the same per-address limiter as before. Both refusals return the page’s own error fragment with a 403, and HTMX does not swap a 4xx reply into the page by default, so the form needs a handler that lets a 403 through. My first version of that handler did not work. I attached it to the form, but HTMX fires the event on the element that receives the reply, and on this page that element is the form’s sibling, so the event never reached the handler and a refused person still saw nothing. A second review of this post’s draft caught it by reading the bundled HTMX against the template. The fix, a fourth change that morning, puts the listener on the page body, scoped to the response box, and a test asserts that it renders. At 08:21 PST, after that deploy, I ran the form in Playwright’s Chromium with the widget’s token replaced by a junk string: the response box showed “error: Verification failed. Reload the page and try again.”, and nothing was sent, because siteverify refuses a junk token before the handler reaches the send.68 The old CSRF refusal, the JSON 403 that HTMX never showed, is gone. The form resets its own widget after every HTMX request, naming the widget’s container as Cloudflare’s reset call takes it, so a retry carries a fresh token, because each token is single-use and lives 300 seconds.126069 Cloudflare’s client-side guide gives the reason and the call: “To reset the widget if the given widget timed out or expired, you can use the function:” turnstile.reset(widgetId);.69 My first version called the reset with no argument, which the guide does not document; a fourth review round caught it, the call now names the container on both sites, and a browser check on November 3 submitted a junk token on each form, saw the refusal, and then found a new token in the hidden input: within eight seconds on the studio site, within twenty-five on this one, where the widget re-ran its challenge first.12 The widget div and the script render only when the site key is set. Eleven tests pass: no secret sends as before; with a secret, the handler refuses a missing token before any send and refuses a rejected token, an accepted token sends, and CSRF still runs first; and the verifier accepts this site’s hostname and refuses another hostname, localhost by default, another action, or none.126646

The studio site. The honeypot stays in front. A filled hidden field still answers the success message and stores nothing, so the cheapest bots never cost a call to Cloudflare. Then comes Turnstile, and a missing, rejected, or unverifiable token gets a 403 with the error fragment. Then validation, the insert into the message table, and the notification.12 Its verifier checks the hostname (now the site’s own two hostnames only) and the action the same way; that change deployed at 07:19 PST, and at 07:22 PST the live page carried the widget and the site key, a token-less post and a junk-token post came back 403, and a honeypot post still answered 200.66 Before deploying, I ran the handler locally against Cloudflare’s documented test keys. With the always-pass secret, a post with no token came back 403 and the dummy token passed Turnstile and reached validation; with the always-fail secret, both came back 403; the honeypot answered 200.1267 Cloudflare’s testing page says why a dummy token cannot pass in production: “Production secret keys: Only accept real tokens, reject dummy tokens.”67

The live check. Earlier that morning, after each site’s own deploy succeeded and a cache purge, at 06:35 PST, each home page carried one widget div, one Turnstile script tag, and the site key, and each zone’s live policy listed the challenge origin in frame-src. A post to this site’s form with a junk CSRF token came back 403, but that is the CSRF check refusing it, and it proves nothing about Turnstile. The Turnstile check itself I tested at 06:43 PST: a post carrying a valid CSRF token taken from the live page and no Turnstile token came back 403 with the message “error: Verification failed. Reload the page and try again.”, and the same post with a junk Turnstile token came back 403 too. So the secret is on the service and the check runs after CSRF. On the studio site, a token-less post and a junk-token post both came back 403, and a honeypot post came back 200 with nothing stored and nothing mailed.70

The browser check. In a real Chromium driven by Playwright, the widget rendered on both pages, the Turnstile script loaded, and the hidden cf-turnstile-response input held a 730-character token on both pages within six seconds.70 That proves less than it seems. Cloudflare’s testing page warns that “Automated testing suites (like Selenium, Cypress, or Playwright) are detected as bots by Turnstile”, and only siteverify decides whether a token passes.67 What the check proves is that the widget runs under both policies and fills its field. What I have not done is the one test that matters most to a reader of this post: a person filling in the form and getting a reply. That test sends an email, so it is mine to run, and I have not run it yet.

7. What the rate limit is still for

Turnstile replaced the rate limits as the gate. It did not replace them as a fence. A bot check answers who; something still has to answer how many, because every submission that reaches the check with a non-empty token of valid size now costs a call to Cloudflare’s siteverify API, and every post that passes still costs a send.12 Cloudflare’s own server-side guide lists rate limiting among its recommendations for the siteverify call itself: “Rate limit to protect against validation flooding.”60 Both of my limits stay: the edge rule at 3 posts per 10 seconds per address per data center, and the app’s limiter at five posts a minute per address.917

The edge rule deserves an honest description now that I know how it counts. Cloudflare states the design plainly: “Cloudflare tracks request rates by maintaining separate counters for each unique combination of values in a rule’s characteristics,” and “Cloudflare does not support global rate limiting counters across the entire network.”6 An address that reaches my zone through two data centers gets two allowances, and the November 2 scanner’s posts did arrive through two; that is my strongest explanation for why the rule stayed quiet, consistent with the minute totals but not proven by them.5 Add Cloudflare’s precision caveat, that the rules “are not designed to allow a precise number of requests to reach your origin server”, and the rule is best described as a nominal budget, not a proven cap: 3 form posts per 10 seconds per address per data center, the nominal equivalent of 18 a minute, enforced approximately.489 On the Free plan it is also the only rate-limiting rule I get, with a fixed 10-second period and counting by IP alone.48

A detail on the Free plan matters if you were tempted, as I was, to make the rate limit itself the challenge. Cloudflare’s parameters page says that on Free, Pro, and Business plans a rate-limiting rule with a challenge action always throttles instead of holding a duration, and that “When visitors pass a challenge, their corresponding request counter is set to zero.”71 A challenge on the form’s XHR POST would also fail for the reason in section 6.63 So the edge rule stays a block, and the challenge lives in the widget.

The app limiter needs the fix its leak calls for: a counter store with an atomic increment, so that no two posts can read the same count, and a failure path that does not split the count across processes. The current code, a KV read followed by a separate write with a per-process fallback, provides neither, so this is a change of store and not a one-line patch.173 With Turnstile as an added condition on every submission that could reach the inbox, a post the leak lets through must still carry a token Cloudflare accepts, so the leak costs volume and money, and the decision about who may post no longer rests on the count. A limiter is a budget. A budget that is off by 47 in one flood is a bad budget, but it was never a guard.

OWASP’s authentication guidance puts the same layering in its own terms. It treats a CAPTCHA “as a defense-in-depth control to make brute-force attacks more time-consuming and expensive, rather than as a preventative.”72 And its credential-stuffing guidance notes that “Most tools used for these types of attacks will make direct POST requests to the server and read the responses, but will not download or execute JavaScript”.50 My two scanners did fetch the site’s pages, the likeliest way they came to hold valid CSRF tokens. A client that posts the form without running the widget has no Turnstile token to send, and both forms now refuse a post without one: on this site a post with a valid CSRF token and no Turnstile token came back 403, and so did a token-less post on the studio site.70

The checklist

For a contact form behind Cloudflare, in the order I would do them now:

  1. Put a bot check on the form and verify every token on the server. Cloudflare’s own words: “The client-side widget alone does not protect your forms.”60 Check the hostname and action in the answer too, as the same page advises, so a token minted elsewhere does not pass, and keep local domains off the production sitekey, because “Cloudflare recommends that sitekeys used in production do not allow local domains”.606667
  2. Fail closed. A missing, oversized, rejected, or unverifiable token is a refusal, because “Tokens can be forged.”6012
  3. Reset the widget after every submission. Tokens are single-use and valid for 300 seconds, so a retry without a reset is a retry with a spent token.6069
  4. Allow the challenge origin in both CSP directives. Cloudflare asks for script-src and frame-src on https://challenges.cloudflare.com, and a policy with only default-src 'self' blocks the iframe.6212
  5. Do not put a managed challenge on a form that posts with XHR or fetch. Cloudflare says the challenge page “fails when the browser expects a non-HTML response”, and an HTMX post is that kind of request.63
  6. Return an error a person can see. My CSRF refusal was a JSON 403 that the HTMX form never displayed, so a person with an expired token would have clicked Send and seen nothing, and my first fix listened on the form while HTMX fires the swap event on the reply’s target, the form’s sibling, so test the refusal in a browser.1268
  7. Keep the honeypot in front of the bot check. It caught 42 of 52 posts on my studio site at the cost of one hidden input and one if.725
  8. Keep the rate limit, and size it as a cost cap, not a gate. Cloudflare counts it per address per data center, and the November 2 scanner arrived through two, the likeliest reason it never fired, though minute totals cannot prove it.65
  9. Back the app limiter with a store that increments atomically. Mine read the count and wrote it back in a separate request, reset it to one when its second read failed, fell back to one process’s memory when KV failed, and passed 47 and 9 sends past its own ceiling; the logs cannot say which path leaked.173
  10. Do not trust a user-agent rule to stop a scanner. Both scanners sent a plain Chrome string, at least 69,322 times on October 30 and 9,725 times on November 2.3241
  11. Count emails at the mail provider, not at the form. My handler answers 200 on its failure paths, and Resend’s email.sent event means only that “the API request was successful”; a retrieved email’s last_event field says what happened next.242873
  12. Query the analytics within a month. Cloudflare’s adaptive datasets keep “at least 31 days of data on every plan”.27
  13. Recount after the window closes. My notes written minutes after the October 30 flood undercounted the form posts, because Cloudflare had not finished ingesting the last minutes.16
  14. Treat an address block as cleanup. Mine came about 50 minutes after the first form post on October 30 and 8 minutes after the scan began on November 2, and no send on either day came after the block minute: 101 before it on October 30, and 26 before it and 5 within it on November 2.1192

What could change

These are conditions, not forecasts. Each one names what would have to happen and what I would do about it.

If a scanner starts passing managed challenges. Turnstile’s checks run in the browser and look for automation; a tool that runs a full browser well enough to pass them, at volume, would turn the widget into one more cost for it and not a wall. The tell would be form sends that carry tokens siteverify accepts, from addresses that also send injection strings in the User-Agent header. Batchelder wrote the honest version of this for honeypots in 2007: “If and when they do, we can switch back to CAPTCHAs.”49 My fallback would be the same in spirit, a stronger challenge, and in the meantime the honeypot and the rate limits still stand.

If I add an edge check with pre-clearance. Cloudflare recommends Turnstile pre-clearance for requests that cannot render a challenge page, and a cf_clearance cookie lets a visitor “bypass WAF Challenges on all subsequent requests on the zone, including API requests”.6563 If I adopt it, the edge could challenge the form’s POST itself without breaking HTMX for a visitor whose cookie holds a valid clearance at the challenge’s level or above, and Cloudflare’s CSP page adds one requirement for that mode: “your connect-src directive must include ‘self’.”62 The cookie comes “in addition to the default Turnstile token”, and siteverify stays required, so the edge challenge would be a second check in front of my server’s, not a replacement for it.65 I have not done it, and the post describes the widget-and-siteverify design that is live.

If I export the Resend log for the two windows. Every email count in this post is a ceiling, because a form 200 is a send, or a failure my handler also answers 200.24 Resend’s retrieve endpoint returns a last_event for each email, such as delivered, and its event list distinguishes “sent - The email was sent successfully.” from “delivered - Resend successfully delivered the email to the recipient’s mail server.”7374 With that export, 101 and 31 would become exact counts, and they could only go down.

If a person sends the spam by hand. Turnstile asks whether a browser and, at times, a person is present. It does not ask why. A person paid to paste a pitch into contact forms can pass it, and so can the lead-generation senders on my studio site if a person is behind them. For that case the defenses are the old ones: a rate limit for volume and my own judgment in the inbox.

If the Tor drip needs a day-by-day answer. The exit list I checked is the one I fetched on November 3, and the Tor Project’s CollecTor service keeps past lists.5355 If I query it for the eight days, the Tor attribution would rest on the lists from the days the posts arrived, not on Cloudflare’s code plus the November 3 list. Cloudflare also offers Onion Routing on every plan, which serves Tor users through the Tor network directly; Cloudflare says that with it, “Human Tor users and bots can be distinguished by our Onion services, such that interactive challenges are only served to malicious bot traffic.”75 I have not turned it on, and I would want to measure the studio site’s Tor traffic after Turnstile before I did.

Key Takeaways

If you run a contact form:

  • A CSRF token and a rate limit together still let a scanner write to you. Both of my scanners held valid tokens, and my limiter let 101 and 31 sends through.12
  • Verify a bot-check token on the server, fail closed, and reset the widget after every request; the widget alone “does not protect your forms.”60
  • Test your limiter under load and under failure, not one request at a time. A read-then-write counter with a per-process fallback passed 18 sends in a minute set to allow five.117
  • Keep the honeypot. It is the cheapest filter in the stack, and it caught 80.8% of these submissions on my studio site, over eight days, before the handler checked an address, stored a row, or sent a mail.725

If you run the site’s edge:

  • Know what your rate limit counts. On Cloudflare every rule counts per data center, and a scanner that arrives through two gets two counters.65
  • Probe your own rules and read the firewall events afterward. Eleven firewall events from my probe told me more about the rule than a day of reasoning about why it had stayed quiet.18
  • Do not challenge an XHR or fetch POST with a challenge page; Cloudflare documents that it fails, and recommends pre-clearance for that case.63
  • Expect user-agent rules to catch only honest tools. The scanners here sent a browser string; my curl test drew challenges.3239

If you are the one reading the inbox:

  • When the inbox fills, check the sender before the subject. Three of my mail streams share one sender address, which is why my first guess was wrong.23
  • Treat a form’s 200s as a ceiling on email, not a count, until the mail provider’s log says otherwise.2428
  • Read the stored messages before you turn the notifier off. One real customer’s message sat unread for a week once, and that message is why the studio form emails me at all.25

FAQ

Why is my contact form getting spam through Cloudflare?

Most likely because nothing in front of the form asks every submission whether a person sent it. Cloudflare’s custom rules match addresses, networks, paths, and user-agent strings, and its rate limits count requests per address per data center; a scanner that sends a browser user agent, loads the page first, and posts at a moderate rate passes all of them. On my site two scanners did exactly that and got 101 and 31 submissions through, and my studio site’s spam arrived mostly one post per address in the eight days I measured, which no per-address count can catch.96127

Does a rate limit stop bots?

A rate limit bounds how fast one address can go; it does not tell a bot from a person. Cloudflare’s own documentation says its rules “are not designed to allow a precise number of requests to reach your origin server” and that counters are not shared across data centers. My edge rule never fired on either scanner, and my app limiter, set to five a minute, passed 18 in one minute: its counter is a read followed by a separate write, a failed second read resets it to one, and a per-process fallback takes over when the store fails; any of those paths can let extra posts through.48641173

Is a honeypot field enough?

It is enough for the bots that fill the hidden field, and on my studio site that was 80.8% of these submissions: mine caught 42 of 52 posts in eight days, including, by the hour they arrived, every post from a Tor exit. It does nothing against a sender that leaves the hidden field empty, and the 10 it let through, stored and so at most 10 emails, did exactly that.720 Ned Batchelder, describing the technique in 2007, said as much: “In theory, it is possible for a spambot to defeat any of these measures.”49

Turnstile vs a CAPTCHA: what is the difference?

A classic CAPTCHA asks the visitor to solve a puzzle. Turnstile runs “a series of small non-interactive JavaScript challenges to gather signals about the visitor or browser environment”, and in managed mode it shows a checkbox only when the visitor’s risk calls for one, with “No images or text to decipher.”2158 Cloudflare says its challenges do not use “CAPTCHA puzzles or visual tests like selecting objects or typing distorted characters.”76 Either one still needs the server to verify the result; for Turnstile that is the siteverify call.60

Why doesn’t CSRF protection stop spam?

A correctly implemented CSRF defense is meant to stop another site from making a visitor’s browser submit your form. My token is weaker than that: it is stateless and signed, valid for an hour, and not tied to a session, a cookie, or an address, so it proves only that the client holds a token my site issued within the hour, not where the request came from. Neither kind stops a client that loads your page and submits the form itself, because loading the page is how a client gets a valid token, and both scanners collected valid ones; the check refused four of their posts across both floods.1512

How can I tell a scanner from a person in the logs?

Look at everything the address did, not only the form. My two scanners crawled thousands of paths, put SQL-injection strings in paths and in the User-Agent header, sent a Chrome user agent for most requests and a Googlebot claim for some that Cloudflare’s verified-bot rule did not accept, and posted the form in bursts of dozens to hundreds a minute.31324441 Time-based payloads such as sleep(15) and waitfor delay '0:0:15' belong to documented probe families.3377 A person sends one message, from a page they actually read, and does not ask your server to sleep.

Does Turnstile collect personal data?

Cloudflare does not say it collects none. Its Turnstile privacy addendum names the signals it processes, “such as client IP address, TLS Fingerprint, User-Agent Header and Sitekey and associated origin”, says the purpose is “solely to detect and block bots”, and says Cloudflare is a data controller of those signals for improving its bot detection. Its overview says Turnstile “does not access, store, or transmit user communications, form entries, or other page inputs.”2221 So it sees connection data, not what a person types.

Related on this site. Claude Code Auto Mode Is Not a Security Boundary makes the same argument about a different control: a classifier that judges each action is not the wall. Agents.txt Is Not Access Control draws the line between declaring a policy to bots and enforcing one. Alert Fatigue: 82 Emails in a Day From My Security Monitor covers the November 2 scanner from my monitor’s side: a block eight seconds after its first finding, eight minutes after the scan began, and zero emails about it. What I Run Before I Sleep is a different kind of operations routine, a nightly check of 15,000 pages for status, response time, and cache, and What Actually Breaks When You Run AI Agents Unsupervised catalogs the failure modes of unsupervised agent sessions, the kind of automation that runs my infrastructure.

Sources

Author’s data notes: every count from my own systems comes from Cloudflare’s GraphQL Analytics API for the two zones (the request and firewall-event datasets), the studio site’s message table, my monitor’s log, and the code of the two forms, all saved on November 3, 2026 and turned into a ledger by script. Times are UTC unless marked PDT or PST. No address, rule or list identifier, key, or filesystem path from that evidence appears in this post.


  1. Author’s data. Cloudflare’s request analytics for this site’s zone, the October 30 scanner’s POSTs to the contact form per minute and by edge status, queried November 3, 2026 over the closed window: 1,065 posts, of which 101 answered 200, 830 answered 429, 130 answered 422, and 4 answered 403, all reaching the app; first post and first 200 in the 18:00 minute, peak 208 posts in the 18:03 minute, most 200s in one minute 18 (18:06), eight minutes with more than five 200s, the last 200 at 18:36 and the last post to reach the app at 18:37. Derived in the ledger: 101 sends before the block, the first post about 46 minutes before I reported the flood and 49.88 minutes before the block, the last post to reach the app 11.88 minutes before the block. ↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩

  2. Author’s data. Cloudflare’s request analytics for this site’s zone, the November 2 scanner’s POSTs to the contact form per minute and by edge status, queried November 3, 2026: 500 posts, of which 31 answered 200, 31 answered 429, and 438 answered 403 at or after the block minute; 62 reached the app; first post in the 18:58 minute, peak 32 posts reaching the app in the 19:02 minute, most 200s in one minute 10 (19:03), last 200 in the 19:04 minute; a mean of 8.86 posts a minute over the seven-minute span from the first post to reach the app, in the 18:58 minute, to the last, in the 19:04 minute; of the 31 sends, 26 in the minutes before the block minute and 5 in the block minute itself, 19:04, the minute the address went on the list at 19:04:01, which minute buckets cannot order against that second; none after it. ↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩

  3. Author’s analysis in the ledger: for each minute, the smaller of five and the posts the app limiter saw (200s, 429s, and the app’s CSRF 403s; not the 422s, which FastAPI refuses before the limiter runs), summed over the minutes. October 30: at most 54 sends for an exact limiter against 101 measured, 47 above it, from 935 posts the limiter saw. November 2: at most 22 against 31, 9 above it, from 62. A counterfactual ceiling, not a measurement; the measured sends are the form counts above. ↩↩↩↩↩↩↩

  4. Author’s data. Cloudflare’s firewall events for the November 2 address, queried November 3, 2026 (a sampled dataset, used for which rule acted and when, never for volumes): the first event at 18:57:11 from the exploit-path rule, the first blocklist-rule event at 19:04:03; every row a block, none a managed challenge; 310 rows on the contact form, every one a block, the first at 19:04:05; and no event from the rate-limiting rule for the address. ↩↩↩↩↩↩↩↩↩↩

  5. Author’s data, ledger addendum. Cloudflare’s request analytics for the two scanners’ form POSTs grouped by Cloudflare data center, minute, and status, queried November 3, 2026: on November 2 from 18:56 to 19:05:00, the query’s end, so the count holds only the first post of the 19:05 minute, 44 posts through Amsterdam and 41 through London; in the 19:02 minute, 18 through London (4 sent, 14 refused by the app) and 14 through Amsterdam (5 sent, 9 refused); the largest count in one minute at one data center, 19, was Amsterdam’s in the 19:04 minute, 15 of them refused at the edge after the block. On October 30, all 1,065 posts came through Paris. ↩↩↩↩↩↩↩↩

  6. Cloudflare, “How Cloudflare determines the request rate”, Cloudflare WAF documentation, fetched November 3, 2026, 14:08:57 UTC. Separate counters per combination of characteristics; counters not shared across data centers except those in the same geographical location; cf.colo.id a mandatory characteristic; no global counters. ↩↩↩↩↩↩↩

  7. Author’s data. Cloudflare’s request analytics for the studio site’s zone, POSTs to the contact form by hour, address, country, and user agent, from 17:00 UTC on October 26 to 13:59:50 UTC on November 3, 2026: 52 posts, all answered 200, from 35 addresses; 23 posts and 23 addresses with Cloudflare’s Tor country code (44.2% and 65.7%); 32 addresses with exactly one post (91.4%), every Tor address among them; the three repeat addresses with 13 (Britain, one hour, the SQLSpider user agent), 2 (Poland, one hour), and 5 (United States, a new user agent each time); Tor user agents 18 Android and 5 desktop Chrome, 21 naming Chrome 81 or older; non-Tor user agents 10 desktop Chrome 125 to 135, 6 Firefox 128 to 138, and 13 other. The zone’s firewall logged no event on a POST to the form in the window. Honeypot trips are posts minus stored rows: 42, or 80.8%. ↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩

  8. Author’s data. The studio site’s contact-message table, read November 3, 2026 at 14:04 UTC: 108 rows from June 6 to 07:24 UTC on November 3, 10 of them in the post-query window; per day as in the table in section 5; 32 rows in October on 21 of 31 days, 1.03 a day, never more than three in a day; the nine $50,000,000 rows under one sender name with nine sender addresses; 43 rows since the notifier commit and 65 before it. ↩↩↩↩↩↩↩↩↩↩↩

  9. Author’s data. The zone configuration of both sites, exported through Cloudflare’s API on November 3, 2026: this site’s rate-limiting rule (version 1, last updated 2026-10-30 at 18:49:53 UTC; expression as quoted; characteristics data center and client address; 3 requests per 10-second period; 10-second mitigation timeout, the nominal equivalent of 18 a minute per address per data center) and its five custom rules (an account-list and seven-ASN block, an exploit-path block, a verified-bot skip, and two managed challenges on the contact form and admin paths, by user-agent string and by threat score, both enabled at the export and the threat-score one disabled later that day); the studio site’s rules of the same kinds, with the challenge site-wide and an API-path block that exempts the form, and a site-wide rate-limiting rule counted per data center and address over requests from clients that are not verified bots. ↩↩↩↩↩↩↩↩↩↩↩↩↩↩

  10. Author’s data. Cloudflare’s firewall events for the October 30 address, queried November 3, 2026 (3,932 sampled rows, under the query cap, used for attribution and timing only): the first event at 17:59:53 from the exploit-path rule, which acted from the scan’s first minute; every one of the 111 sampled events before 18:49:53 from that rule; the first blocklist-rule event at 18:49:53; no event from the rate-limiting rule and no event on the contact form for the address. ↩↩↩↩↩↩↩↩↩↩↩

  11. Cloudflare, “cf.threat_score”, Ruleset Engine fields reference, fetched November 3, 2026, 16:12:44 UTC. The field’s description: a score that once ran from 0 to 100, and “Now, the threat score is always 0 (zero).” ↩↩↩

  12. Author’s record of what shipped on November 3, 2026, written from the session’s tool output: the two widgets (managed mode, created through Cloudflare’s API at about 14:13 UTC), the CSP changes on both zones, this site’s verifier and handler order (fails closed, CSRF then Turnstile then the send, 403 error fragments, widget reset after every HTMX request), its ten new tests (10 passed; an eleventh came with the hostname check), the studio site’s handler order (honeypot, then Turnstile, then validation, storage, and notification), and the local checks against Cloudflare’s test keys; on both sites the verifier refuses a missing token, or one longer than 2,048 characters, without calling siteverify (read from the code). Both sites deployed between 14:32 and 14:36 UTC. ↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩

  13. Author’s data. Cloudflare’s request analytics for the October 30 address, per minute and in a sweep of every zone in my account, queried November 3, 2026: 76,429 requests to this site from the 17:59 minute to the 18:51 minute (53 buckets), the same total in both queries; 69,484 before the block minute at a mean of 1,389.7 a minute, a peak of 3,219 at 18:36, 5,234 after the block minute with none served; by outcome 49,513 served (64.8%), 18,990 refused by the app (24.8%, including 10,019 GET 418 from the site’s agent gate), 2,339 origin 520s (3.1%), 5,587 blocked at the edge (7.3%); 50.88 minutes from the scan’s first minute to the block; 387 GETs to kiradex.app, all answered 200, and no other zone touched. ↩↩↩↩↩↩↩↩↩↩

  14. Author’s data. Cloudflare’s request analytics for the November 2 address, per minute, by path, by user agent, and in a sweep of every zone, queried November 3, 2026: two GETs at 07:49 UTC, 11.1 hours before the scan; 10,907 requests from the 18:56 minute to the 19:17 minute (22 buckets), 10,909 with the early GETs, the same total in four queries; 29 requests in the first minute, 637 in the busiest minute before the block (18:57), 1,107 in the busiest minute (19:07), 300 in the last; 2,986 before the block minute (a mean of 373.2 a minute), 7,445 after it with none served; by outcome 1,388 served (12.7%), 1,473 refused by the app (13.5%, including 420 GET 418), 116 origin 520s (1.1%), 7,930 blocked at the edge (72.7%); 8.02 minutes from the scan’s first minute to the block; 368 GETs to kiradex.app and no other zone touched. ↩↩↩↩↩↩↩↩↩↩↩

  15. Author’s code reading. This site’s CSRF module as deployed on October 30 and November 2: a token of timestamp, random value, and HMAC-SHA256 signature over the two; valid if the signature matches and the timestamp is under 3,600 seconds old; not bound to a session, cookie, or address. Read from the source on November 3, 2026. ↩↩↩↩

  16. Author’s data. The ledger built by script from the saved query results (484 rows, each with its source and method) and its codebook, November 3, 2026, including the rules for which layer answered each status, the rule that firewall events give attribution and timestamps and request datasets give counts, the rule that only a 200 proves a valid CSRF token, and the table of where my notes from the day differ from the November 3 queries (the October 30 notes were written at 11:58 PDT, before Cloudflare had ingested the last minutes, and undercount the form posts, the 422s, the paint-share posts, and the hour’s requests). ↩↩↩↩↩↩↩↩

  17. Author’s code reading. This site’s form rate limiter as deployed on October 30 and November 2: a default of five form posts per client address per window, a 60-second window keyed on the clock minute, a KV read of the counter, a second read inside the increment that counts a failed read as zero, and a separate KV write of that count plus one, a 429 when the count read is at the limit, and a fallback to a per-process in-memory list when the KV call fails. Read from the source on November 3, 2026. ↩↩↩↩↩↩↩↩↩↩

  18. Author’s data, ledger addendum. My probe of this site’s rate-limiting rule on November 3, 2026 from my own machine: at 14:36:49 UTC, ten token-less POSTs 0.4 seconds apart (four 422, then six 429); at 14:38:03 UTC, eight with every field present (three 403 from the origin, then five 429 with the body “error code: 1015”); Cloudflare’s firewall events for the form from 14:35 to 14:40 UTC, fetched at 14:40:36 UTC: 11 events, every one a block from the rate-limiting rule. ↩↩↩↩

  19. Author’s data. My monitor’s log for November 2, 2026, 10:40 to 11:32 PST, and its finding and block records, saved November 3: a run started at 10:57:00 PST with a detector window ending 18:57:22 UTC and no finding, the address’s requests in that window between 29 and 666 by the per-minute data; the next run at 11:02:46 PST; the first finding at 11:03:53 PST (a critical request flood, 1,914 requests counted for the address), 7.88 minutes after the scan’s first minute; the address added to the account blocklist at 11:04:01 PST; six findings in all, every one folded into the daily digest and none emailed. The monitor was not running on October 30; I re-armed it on October 31, and its traffic detector came later. ↩↩↩↩↩↩↩↩↩↩↩↩↩↩

  20. Author’s analysis. Hour-bucket matching of the studio site’s stored rows against the hourly POST data, November 3, 2026: no stored row in an hour with only Tor posts; all 10 in hours with a non-Tor post; 22 of 23 Tor posts in hours with no stored row, the 23rd in an hour shared with the US lead-generation address; the five lead-generation rows in the window in the five hours that address posted. An inference by hour, not a join on a request id. ↩↩↩↩↩↩↩

  21. Cloudflare, “Overview”, Turnstile documentation overview, fetched November 3, 2026, 14:08:57 UTC. “Cloudflare’s smart CAPTCHA alternative”; embedding without Cloudflare traffic; the non-interactive JavaScript challenges and the proof-of-work and proof-of-space description; the statement that Turnstile does not access, store, or transmit form entries. ↩↩↩↩↩↩

  22. Cloudflare, “Turnstile Privacy Addendum”, last updated June 18, 2025, fetched November 3, 2026, 14:08:57 UTC. The signals Turnstile processes, their stated purpose, and Cloudflare’s role as data controller for improving bot detection, all quoted. ↩↩↩

  23. Author’s Captain’s Log, November 2, 2026: my question to the agent session that runs my infrastructure, whether it was sending the emails, the three mail streams that share one sender address, the emails’ “Contact Form:” subjects carrying the scanner’s payloads, and my note that the scanner’s form posts “ran about nine a minute”. ↩↩↩↩↩

  24. Author’s code reading. This site’s contact handler and form template as deployed on October 30 and November 2: an HTMX form (hx-post to the form path) with the CSRF token rendered in; the limiter decorator before the handler; the CSRF check, raising a 403, before the send; the send through Resend with the subject “Contact Form:” and the sender’s name; the success fragment on an accepted send; and two failure paths, a missing mail configuration and a failed or id-less send, that return the error fragment with FastAPI’s default status, 200. No honeypot field. Read from the source on November 3, 2026. ↩↩↩↩↩↩↩↩↩

  25. Author’s code reading and repository history for the studio site as deployed before November 3, 2026: the contact handler’s honeypot branch (a non-empty hidden website field answers the success fragment and stores and mails nothing; it is the handler’s first check, ahead of the address check, the insert, and the notifier), present since the commit that built the form on June 5, 2026; the hidden input’s tabindex="-1" and aria-hidden; no CSRF token; the best-effort notifier, added on September 21, 2026 at 17:46:47 UTC in a commit whose message says “a paying customer’s message sat unread in D1 for a week”; and an in-memory per-process rate limiter for anonymous clients, keyed by client address and client classification (the bot tier the limiter assigns from the user agent and address), over every path except health checks and static files. Read from the repository on November 3, 2026. ↩↩↩↩↩↩↩↩↩↩↩↩↩↩

  26. Author’s Captain’s Log, October 30, 2026, written at 11:58 PDT: my report of the flood at 11:46 PDT, the hand block and the rate-limiting rule at 11:48 to 11:49 PDT, and my test at 11:52 PDT, quoted. Its counts were taken while Cloudflare was still ingesting the flood’s last minutes, and this post uses the November 3 queries instead. ↩↩↩

  27. Cloudflare, “GraphQL API - Limits”, GraphQL Analytics API documentation, fetched November 3, 2026, 14:08:57 UTC. Adaptive datasets “retain at least 31 days of data on every plan”. ↩↩

  28. Resend, “Event Types”, Resend webhook documentation, fetched November 3, 2026, 14:14:47 UTC. email.sent “Occurs whenever the API request was successful. Resend will attempt to deliver the message to the recipient’s mail server.”; email.delivered for successful delivery. ↩↩↩

  29. Author’s data. Cloudflare’s request analytics for every other address that posted this site’s contact form in the eight-day query, excluding the two scanners and my test address, queried November 3, 2026: three addresses (South Korea, Japan, India), each with exactly two 200s on one day, October 27, 28, and 29. ↩

  30. RIPE NCC, RIPEstat network-info, whois, and as-overview for the October 30 address and its AS, fetched November 3, 2026, 14:10:34 and 14:11:34 UTC: AS34534, holder “BULLIONET FBW NETWORKS SAS”, an allocation registered with country FR. ↩

  31. Author’s data. Cloudflare’s request analytics for the October 30 address grouped by path, method, and status, queried November 3, 2026; the query returned its 3,000-row limit, covering 74,125 of 76,429 requests, so path counts are lower bounds: at least 2,881 distinct paths and at least 2,680 requests with an SQL-injection payload in the path across at least 810 distinct paths; 320 POSTs to the paint share path, all refused (422 or 400), and none from any other address in eight days; the payload variants of the share path quoted in section 2, answered 404. ↩↩↩↩

  32. Author’s data. Cloudflare’s request analytics for the October 30 address grouped by user agent, queried November 3, 2026; the query returned its 500-row limit, covering 76,095 of 76,429 requests, so every count here is a lower bound: at least 69,322 requests with one plain Chrome 141 Windows string, at least 2,755 with a Googlebot claim, and at least 3,154 with an SQL-injection payload in at least 396 distinct strings. ↩↩↩↩↩↩

  33. PortSwigger Web Security Academy, “SQL injection cheat sheet”, fetched November 3, 2026, 14:10:34 UTC. Time delays for Oracle (dbms_pipe.receive_message), Microsoft (WAITFOR DELAY), PostgreSQL (pg_sleep), and MySQL (SLEEP); the conditional IF(condition, SLEEP(10), 'a') form; SELECT @@version for Microsoft and MySQL; Oracle’s FROM dual. ↩↩↩↩↩

  34. sqlmap project, “time_blind.xml”, the tool’s time-based blind payload definitions, fetched November 3, 2026, 14:12:27 UTC. Contains SLEEP, PG_SLEEP, WAITFOR DELAY, and DBMS_PIPE.RECEIVE_MESSAGE payloads. Cited for the families, not as the tool either scanner used. ↩

  35. PortSwigger Web Security Academy, “Blind SQL injection”, fetched November 3, 2026, 14:10:34 UTC. Why a time delay in a query delays the HTTP response, quoted. ↩

  36. OWASP Automated Threats to Web Applications, “OAT-014 Vulnerability Scanning”, fetched November 3, 2026, 14:13:41 UTC. The summary, quoted. ↩

  37. Author’s data, ledger addendum. Cloudflare’s request analytics for the two scanners’ form POSTs grouped by edge status and origin status, queried November 3, 2026: on October 30, 395 of the 830 429s carry an origin status of 429 and the other 435 none recorded; on November 2, 16 of the 31 429s carry an origin 429 and 15 none recorded. No 429 carries an edge-only status where the origin’s was recorded. ↩↩

  38. Author’s test, November 3, 2026: a ten-line FastAPI app run with the site’s own environment (FastAPI 0.128.0), with a limiter-style decorator on the endpoint. A POST missing a required field returned 422 with the wrapper never called; a complete POST reached the wrapper. ↩

  39. Author’s data. Cloudflare’s firewall events and request analytics for my own test address on October 30, 2026, queried November 3: three events from the rate-limiting rule, the first at 18:52:38 UTC, with a browser user agent, and 24 managed challenges from the user-agent challenge rule for my curl posts. Excluded from every incident count. ↩↩↩↩↩

  40. RIPE NCC, RIPEstat network-info, whois, and as-overview for the November 2 address and its AS, fetched November 3, 2026, 14:10:34 and 14:11:34 UTC: AS24875, holder “NOVOSERVE-AS NovoServe B.V.”, a route registered to NovoServe B.V., country NL. The sub-assignment’s description names a private individual and is not repeated here. ↩

  41. Author’s data. Cloudflare’s request analytics for the November 2 address grouped by user agent, queried November 3, 2026, complete (73 strings covering all 10,909 requests): 9,725 requests with one plain Chrome 141 Windows string, 942 with a Googlebot claim, 54 distinct SQL-injection payloads in 149 requests, 3 quote-breaking strings in 78, 12 @@ marker tokens in 12, and 2 ordinary others. ↩↩↩↩↩↩

  42. Author’s data. The user-agent tables for both days, built by the ledger script on November 3, 2026: the plain Chrome 141 Windows string and the Googlebot string are byte-identical on October 30 and November 2. ↩

  43. Author’s replay, November 3, 2026, of my monitor’s own detectors on complete Cloudflare data for 18:58:23 to 19:03:23 UTC on November 2, the window of the run that started at 11:02:46 PST, run at 15:55:04 UTC and saved as a replay record with the exact commands and the detectors’ full output; five rows of the ledger’s addendum are derived from that record by script: 2,143 requests from the address in the request-flood finding, 1,055 refused requests in the scanner finding, and a form-flood finding from 39 posts with 23 refused, at high severity, which goes to the daily digest and neither emails nor blocks. The live run’s 1,914, in a window that ended the moment the run queried, is what my monitor’s own log recorded, and the live run raised no form-flood finding. The window change made the same day: each live window now ends 90 seconds before the run and starts where the previous one ended, with a plain five-minute look-back when the previous record is missing or old. ↩↩↩

  44. Author’s data. Cloudflare’s request analytics for the November 2 address grouped by path, queried November 3, 2026, complete (2,318 rows covering all 10,909 requests): 2,173 distinct paths, and 778 requests on 542 distinct paths with an SQL-injection payload. ↩↩

  45. OWASP Web Security Testing Guide v4.2, “Testing for SQL Injection”, fetched November 3, 2026, 14:12:12 UTC. The error-based and time-delay techniques, quoted. ↩

  46. Author’s record of the third change on November 3, 2026, made after a review of this post’s draft: at 16:12 UTC both widgets’ allowed domains set to the site and its www host, localhost removed; both verifiers’ default hostname lists set to the same two hostnames, with local development setting its own list; this site’s tests assert that localhost is refused by default (11 pass); the studio site redeployed, and, run locally against Cloudflare’s always-pass test key, a dummy token still reached validation with that key’s placeholder hostname listed and was refused with it unlisted. At 16:13 UTC the two managed-challenge rules keyed on the threat score, one on each zone, were disabled, with the reason in their descriptions; the user-agent challenges stay. Live checks after this change: the studio site at 16:15 UTC (widget, token-less and junk-token posts 403, honeypot 200), this site at 16:18 UTC (widget, token-less post 403); none exercises the hostname rule. ↩↩↩

  47. Cloudflare, “Custom rules”, Cloudflare WAF documentation, fetched November 3, 2026, 14:10:34 UTC. What custom rules do, quoted, and the Free plan’s five rules with every action except Log. ↩

  48. Cloudflare, “Rate limiting rules”, Cloudflare WAF documentation, fetched November 3, 2026, 14:08:57 UTC. The rule parameters; the precision caveat and the counter-update delay, quoted; the availability table, which gives the Free plan a 10-second counting period, a 10-second mitigation timeout, counting by IP, and one rule. ↩↩↩↩↩↩

  49. Ned Batchelder, “Stopping spambots with hashes and honeypots”, created January 21, 2007, fetched November 3, 2026, 14:10:34 UTC. The honeypot definition, the reason bots fall for invisible fields, the playback and form-filling taxonomy, and his two sentences on bots defeating the measures and switching back to CAPTCHAs, all quoted. The page describes the technique as how he protects his site; it does not claim to have invented it. ↩↩↩↩↩↩

  50. OWASP Cheat Sheet Series, “Credential Stuffing Prevention Cheat Sheet”, fetched November 3, 2026, 14:10:34 UTC. Not relying on a single predictable volume limit; low per-address volume defeating block lists and rate limiting; tools that post directly without executing JavaScript, all quoted. ↩↩↩

  51. Cloudflare, “Cloudflare HTTP headers”, Cloudflare Fundamentals documentation, fetched November 3, 2026, 14:14:20 UTC. The CF-IPCountry header’s special codes, including “T1 - Used for clients using the Tor network.” ↩

  52. Cloudflare, “IP Access rules parameters”, Cloudflare WAF documentation, fetched November 3, 2026, 14:14:20 UTC. “T1: Tor exit nodes (country name: Tor)”. ↩

  53. The Tor Project, “Bulk Tor Exit Exporter”, check.torproject.org, fetched November 3, 2026, 14:10:34 UTC. The list’s description, quoted, and the pointer to CollecTor for past data; the list itself, fetched from https://check.torproject.org/torbulkexitlist at the same time, held 1,239 addresses. ↩↩

  54. Author’s data, ledger addendum. Every distinct address that posted the studio site’s form in the eight-day query, split by Cloudflare’s country code, compared by script against the Tor Project’s bulk exit list fetched at 14:10:34 UTC on November 3, 2026 (1,239 addresses): 23 of the 23 addresses coded T1 on the list, and 0 of the 12 others. The list of November 3 only; the event days were not checked against the archive. ↩

  55. The Tor Project, “Sources”, Tor Metrics, fetched November 3, 2026, 14:10:34 UTC. TorDNSEL exit lists “containing the IP addresses of relays that it found when exiting through them”, and their archive. ↩↩

  56. OWASP Automated Threats to Web Applications, “OAT-017 Spamming”, fetched November 3, 2026, 14:10:34 UTC. The summary, the other names including form spam, and the exclusion of form-to-email abuse aimed at unintended recipients, quoted. ↩↩

  57. Author’s analysis. The studio site’s 108 stored messages sorted by fixed rules, first match wins (sender-name match for the price question and the prize lures, message text for the rest, one row placed by hand as a real inquiry), November 3, 2026: the counts in the table in section 5; the price question’s 25 languages over the table and 9 in October, by my reading of each fixed sentence, with Croatian (or Bosnian) and Indonesian (or Malay) as close calls; every row in the post-query window classified as spam. ↩↩↩↩↩

  58. Cloudflare, “Turnstile widgets”, Turnstile documentation, fetched November 3, 2026, 14:08:57 UTC. The managed, non-interactive, and invisible modes and what a visitor sees in each, quoted for managed; invisible mode’s privacy-policy condition; the challenges.cloudflare.com origin. ↩↩↩

  59. Cloudflare, “Cloudflare Turnstile plans”, Turnstile documentation, fetched November 3, 2026, 14:08:57 UTC. Free and Enterprise; up to 20 widgets; unlimited challenges; use without other Cloudflare services, quoted. ↩

  60. Cloudflare, “Validate the token”, Turnstile server-side validation documentation, fetched November 3, 2026, 14:08:57 UTC. The requirement to call siteverify; forged tokens; the endpoint and its secret, response, and remoteip fields; the 300-second validity and single use; the 2,048-character maximum; the action and hostname check; the error-code table and its two descriptions of expiry; rate limiting against validation flooding. All quoted passages are from this page. ↩↩↩↩↩↩↩↩↩↩↩↩↩↩

  61. Cloudflare, “Cloudflare Turnstile - Easy CAPTCHA Alternative”, product page, fetched November 3, 2026, 14:08:57 UTC. “Turnstile never harvests data for ad retargeting.” ↩

  62. Cloudflare, “Content Security Policy”, Turnstile documentation, fetched November 3, 2026, 14:08:57 UTC. script-src and frame-src for https://challenges.cloudflare.com; the nonce-based approach; the connect-src 'self' requirement for pre-clearance, quoted. ↩↩↩

  63. Cloudflare, “Interstitial Challenge Pages”, Cloudflare Challenges documentation, fetched November 3, 2026, 14:08:57 UTC. How a challenge page holds the request; managed challenges recommended for most WAF rules; the AJAX and XHR (fetch) limitation and the recommendation of Turnstile pre-clearance, both quoted. ↩↩↩↩↩↩

  64. Cloudflare, “Detect a Challenge Page response”, Cloudflare Challenges documentation, fetched November 3, 2026, 14:10:34 UTC. The cf-mitigated header set to challenge and the text/html content type of every challenge, quoted. ↩

  65. Cloudflare, “Clearance”, Cloudflare Challenges documentation, fetched November 3, 2026, 14:10:34 UTC. The cf_clearance cookie and pre-clearance, quoted: the cookie is issued in addition to the default one-time token, Siteverify stays required, and a cookie bypasses challenges up to the clearance level set on the widget. ↩↩↩

  66. Author’s record of the second change on November 3, 2026, made the same morning: the studio site deployed at 15:19 UTC and checked live at 15:22 UTC (widget, script, site key, and the challenge origin in the live frame-src; a token-less post and a junk-token post refused 403; a honeypot post answered 200); this site built from 15:20 UTC, its cache purged, and checked live at 15:25 UTC (the same page checks, a token-less post refused 403, then a post with a valid CSRF token from the live page and no Turnstile token refused 403 again, which shows the form fails closed after the deploy but cannot exercise the hostname check); both verifiers now require the hostname in Cloudflare’s answer to be one of the site’s own (an answer with none is refused) and, when Cloudflare returns an action, the widget’s own; this site’s eleven tests pass, the new one accepting the site’s hostname and refusing another hostname, another action, and a missing hostname; on the studio site, run locally against Cloudflare’s always-pass test key, which answers with a placeholder hostname and no action, a dummy token reached validation with that hostname listed and was refused with it unlisted. ↩↩↩↩

  67. Cloudflare, “Test your Turnstile implementation”, Turnstile documentation, fetched November 3, 2026, 14:08:57 UTC. The always-pass and always-fail test keys and the dummy token; production secrets reject dummy tokens; automated testing suites detected as bots, quoted. ↩↩↩↩↩

  68. Author’s record of the fourth change on November 3, 2026, made after a second review of this post’s draft: the first version’s handler for HTMX’s before-swap event, which lets a 403 fragment through HTMX’s default of not swapping a 4xx reply, was attached to the contact form, while HTMX fires that event on the swap target, the form’s sibling, so a 403 fragment was never shown; the review found it by reading the bundled HTMX and the template. The fix is a listener on the page body scoped to the response target’s id, and the home-page test asserts that it renders (11 tests pass). After the deploy was checked at 16:18 UTC, a browser check at 16:21 UTC in Playwright Chromium replaced the widget’s 730-character token with a junk string, filled the required fields with placeholder values, and submitted the form through HTMX; the response target showed the error fragment, “error: Verification failed. Reload the page and try again.”, and no success fragment, and nothing was sent. Not done: a person’s end-to-end submission. ↩↩

  69. Cloudflare, “Embed the widget”, Turnstile documentation, fetched November 3, 2026, 14:08:57 UTC. The api.js script, the widget container, the hidden cf-turnstile-response input created inside a form, and turnstile.reset(widgetId) for timed-out or expired widgets, quoted. ↩↩↩

  70. Author’s record of the live checks on November 3, 2026, each after its own site’s deploy had succeeded, and after the cache purge: at 06:35:24 and 06:35:54 PST, one widget div, one script tag, and the site key on each home page, the challenge origin in each live frame-src, on this site a post with every field present and a junk CSRF token refused 403 by the CSRF check; at 14:43 UTC, on this site, a post with a valid 108-character CSRF token from the live page and no Turnstile token refused 403 with “Verification failed. Reload the page and try again.”, and the same post with a junk Turnstile token refused 403; on the studio site a token-less post and a junk-token post refused 403 and a honeypot post answered 200 with nothing stored or mailed; in Playwright Chromium at 14:39:50 and 14:40:04 UTC, the widget rendered (602 by 73 and 557 by 71 pixels), the Turnstile API object loaded, and the hidden response input held a 730-character token on both pages within six seconds. Not done: a person’s end-to-end submission. ↩↩↩

  71. Cloudflare, “Rate limiting parameters”, Cloudflare WAF documentation, fetched November 3, 2026, 14:08:57 UTC. The parameter names; on Free, Pro, and Business plans a challenge action always throttles, paraphrased; a passed challenge resets the counter, quoted. ↩

  72. OWASP Cheat Sheet Series, “Authentication Cheat Sheet”, fetched November 3, 2026, 14:10:34 UTC. CAPTCHA as a defense-in-depth control, quoted. ↩

  73. Resend, “Retrieve Sent Email”, Resend API reference, fetched November 3, 2026, 14:14:47 UTC. A retrieved email’s last_event field, for example “delivered”. ↩↩

  74. Resend, “View and manage emails”, Resend documentation, fetched November 3, 2026, 14:15:12 UTC. The email event list, including “sent” and “delivered”, quoted, and “queued” for Broadcasts and Batches only. ↩

  75. Cloudflare, “Onion Routing and Tor support”, Cloudflare Network documentation, fetched November 3, 2026, 14:08:57 UTC. Onion Routing on every plan and the distinction between human Tor users and bots, quoted. ↩

  76. Cloudflare, “Challenges”, Cloudflare Challenges documentation, fetched November 3, 2026, 14:08:57 UTC. “Cloudflare does not use CAPTCHA puzzles or visual tests like selecting objects or typing distorted characters.” ↩

  77. OWASP Community, “Blind SQL Injection”, fetched November 3, 2026, 14:10:34 UTC. Time-based blind injection relying on the database pausing, with WAIT FOR DELAY and pg_sleep() as examples. ↩

Related Posts

Alert Fatigue: 82 Emails in a Day From My Security Monitor

One digest a day took my security monitor from 82 emails in a day to 19 the next: the policy, a scanner blocked with zer…

56 min read

The Fork Bomb Saved Us

The LiteLLM attacker made one implementation mistake. That mistake was the only reason 47,000 installs got caught in 46 …

7 min read

What I Refuse To Write About

A blog cluster's voice comes from what it refuses to publish, not what it ships. Categorical, pattern, and interesting r…

11 min read