The Bot That Audited My Rate Limiter
A script sprayed junk at my scan API and got 40 requests past a 3-per-week limit in one second. Tracing why led to a race condition, four siblings of the same bug, and a brute-force limiter that had never worked at all.
contents (4)
This morning I opened the WebSentry admin dashboard and found a stranger’s work waiting for me. Someone had scanned message, data, connect, event, test and ready. Not websites. Just words.
Before the story, the status: every issue described in this post was fixed, deployed, and verified with concurrency tests before publication. No customer data was touched, no paid features were bypassed, and the login form sat behind a CAPTCHA throughout.
The user agent said node. The timestamps said all of it happened at 04:22:00. The same second. My anonymous rate limit is three scans per week per IP, and this script had just run forty requests through it like it wasn’t there.
Those bare words are the standard event names of Node’s EventEmitter, so my best guess is a broken scraper that iterated over its own socket events instead of a URL list. Whoever wrote it probably never looked at the results. But their bug found two of mine.
Bug one: the limiter that counts before it writes
The rate limiter did what almost every hand-rolled rate limiter does:
const used = await countScansThisWeek(ip);if (used >= 3) return tooManyRequests();await recordScan(ip);Read, decide, write. Three steps. It works perfectly when requests arrive one at a time, and my requests had always arrived one at a time, because I was the one sending them.
Forty parallel requests arrive. Each one runs in its own Workers isolate, which is the whole point of the platform: no shared server, no queue, every request gets its own copy of the code. All forty copies execute the count at effectively the same moment, before any of them has written a record. All forty see used = 0. All forty proceed.
The count each request checked was stale the instant it was read. This is a time-of-check to time-of-use race, and the fix is to stop separating the check from the write:
INSERT INTO rate_limits (identifier, action, created_at)SELECT ?1, 'scan', datetime('now')WHERE (SELECT COUNT(*) FROM rate_limits WHERE identifier = ?1 AND action = 'scan' AND created_at > datetime('now', '-7 days')) < 3One statement. By combining the limit check and the insertion into a single write, the database evaluates the condition as part of the write itself, instead of trusting a separate, potentially stale application-level count. The handler checks meta.changes and returns 429 when nothing was inserted.
Then I tested it the way the bot would. Every limiter now uses this same statement shape, so I burst-tested two of them against fresh windows. The free-tools gate, limit thirty per day: forty parallel requests, exactly thirty admitted, ten refused, and exactly thirty rows in the table afterwards. A second burst of forty was refused entirely. The login gate, limit five: ten parallel attempts, exactly five admitted, five refused. Not six, not four. The gates hold under the same shape of traffic that originally broke them.
A bug this obvious is never an only child
Check-then-write is not a mistake you make once. It’s a pattern you reach for every time you enforce a limit, which means it was everywhere. I audited every count-based limit in the codebase the same afternoon and found four more:
| Limit | Race window | Severity |
|---|---|---|
| Monitors per plan | Several seconds. A full scan ran between the check and the insert | A paying user could script unlimited monitors |
Coupon max_uses |
Milliseconds, and the plan was granted before the redemption was recorded | Overspent coupons |
| Team seats | Milliseconds, on both invite and accept | Teams over their seat limit |
| Login attempts | See below | See below |
All four got the same treatment: the condition moved inside the statement that writes. UPDATE coupons SET times_used = times_used + 1 WHERE id = ? AND times_used < max_uses, then check how many rows changed. Unique indexes went in underneath the duplicate checks, so even a future code bug can’t recreate the problem.
Bug two: the limiter that never limited
The login rate limiter was worse than racy. It had never worked.
It computed its time window in JavaScript and compared it against rows in SQLite:
const windowStart = new Date(...).toISOString();// "2026-10-10T12:15:00.000Z"SQLite’s datetime('now') stores these timestamps as text: 2026-10-10 12:15:00. A space where the ISO string has a T. The comparison was therefore a string comparison, and since a space sorts before T, a same-day row always compared as earlier than the window start, while rows from previous days were already outside it. Every comparison the query ever ran came out false. The count was always zero. The five-attempts-per-fifteen-minutes limit had never fired, not once, not even against requests sent politely one at a time.
The only thing protecting the login form from brute force was the CAPTCHA in front of it. The limiter also caught database errors and returned true, which means it failed open on top of never working.
The rewrite uses datetime('now', '-15 minutes') on both sides, so the formats can’t disagree, uses the same atomic insert as everything else, and fails closed. If the database is down, you don’t get to guess passwords while it reboots. While verifying the fix I also checked that every authentication endpoint actually calls the limiter, and found one that didn’t. It does now. Fixes have a way of auditing their own neighborhood.
What I actually learned
Your test traffic is a lie. Every limit I had written worked flawlessly against the traffic I generated testing it, because I am one person clicking one button. Concurrency bugs don’t appear until someone sends traffic shaped differently from yours, and the first person to do that was a broken bot at four in the morning.
Compare timestamps in one system. The moment a JavaScript date string meets a SQLite date string, you are one format detail away from a condition that is silently always false. Generate the window boundary in the database that stores the rows.
Fail closed on the security path. A limiter that returns “allowed” when the database errors is a limiter that switches itself off under exactly the conditions an attacker can sometimes create.
Count-then-write is a disease, not a bug. If you find one instance, grep for the rest the same day. Mine had five expressions of the same pattern, written months apart, each one feeling perfectly reasonable at the time.
The bot cost me nothing. Its forty requests produced twenty-five junk scan records, because repeats of the same made-up hostname were served from the result cache rather than scanned again. One statement deleted them, and the bot’s own flood of rate-limit records now keeps its IP blocked for the week.
It wasn’t a penetration test. It was an accidental concurrency test, run by someone who will never know they ran it. But it reached the one bug class my own testing never could, and following it to the root took me to a disabled brute-force limiter I did not know I had. I would have paid for that, and I’m glad I didn’t have to.