My Rate Limiter Asked the Wrong Question
Astrika had two bad days inside ten. The first one I caused myself by sending a marketing email that killed my own login system. The second was 64 signups from 50 Tor exit nodes at roughly one an hour, slow enough that every control I had ever written waved it through.
Astrika is a Jyotish product I built and run on my own. Between the 11th and the 17th of September it had two bad days, and neither of them looked the way I had imagined a bad day would look.
The first one I caused myself. The second one someone did to me on purpose, patiently, at about one request an hour.

The day I took down my own login
I sent a campaign email to 207 people. Resend’s limit on that account is 100 a day, and the limit is per account, not per sending domain. My login OTPs go out through the same account.
So the campaign ate the quota, and then real users trying to sign in got nothing. Marketing mail and authentication mail were sharing one number, and that dependency existed nowhere except inside the provider.
Fixed the same day by splitting them. Campaigns moved to Brevo on hello@mail.astrika.in, transactional stayed on Resend at noreply@astrika.in. All 47 retries delivered, none failed.

The detail worth keeping from that day has nothing to do with email. The box blocks outbound 25, 465 and 587. Only 2525 is open. Port 587 does not refuse the connection, it hangs for two minutes and then gives up, which reads like a frozen app rather than a blocked port. And my laptop’s 587 is open, so testing from the Mac gives you a green light that means nothing. Probe from inside the container or do not bother probing.
The day the users table filled up with strangers
On the 16th I opened the admin panel and the users table was full of names like Ogoqimhb Ftipo. My first assumption was the worst one: the database had been replaced.
It had not. Users had gone from 281 to 347, so the base had grown, not shrunk. 28 paid orders, 588 wallet transactions, 207 readings, 862 daily horoscopes, all intact. Mongo requires auth and publishes no host port. The admin audit log had one entry, a credit grant I made myself on the 10th.
What was real: 64 accounts created between the 13th and the 17th, from roughly 50 Tor exit nodes, at about one an hour. Generated names. Zero readings, zero orders, zero phone numbers between all of them.
The emails were the tell. They were real addresses harvested from elsewhere. A US federal domain, two county governments, a university, a TV network, three different people at the same media agency.
Nobody wanted an account on my product. They wanted my verification email. This is signup form abuse, where your transactional mail becomes the delivery vehicle for someone else’s harassment and your sending domain absorbs the reputation damage. Six of those addresses verified, which means the mail did land in inboxes that never asked for it, five days after I had finished repairing deliverability.

What actually failed
Not the absence of security. Astrika had a global 300 per minute rate limit, trustProxy with header spoofing protection, NoSQL injection guards, disposable email blocking, per route caps on signin and OTP and password reset, and a signup velocity counter.
Every single one of them keys on speed, or on IP address. The attacker was slow and had fifty addresses.

The part that actually stung was a comment I had written months earlier next to the signin route. It says that an attacker spraying many different emails from rotating IPs is not stopped by a per email cap, and that this case is bounded by the global per IP limiter instead. I had found the hole, written it down, and then named as my fallback the one control that Tor exists to defeat.
A rate limiter answers how fast this IP is going. It cannot answer whether there is a human here, and only the second question told these 64 apart from real users. No velocity threshold catches one signup an hour from fifty addresses. There isn’t a number you can tune.

Five commits, in the order they went out
Turnstile gate, a global 24 hour signup ceiling, and a per route cap on signup. The ceiling is deliberately IP independent, because IP is the dimension that failed. The per route cap is keyed on IP and explicitly not on email, since keying a signup limiter on the email address hands the attacker a fresh bucket for every address they invent.
A fix for the outage I nearly shipped in the commit above. The ceiling first counted auth events of type signup, google and otp. Google and OTP rows get written on every login, not only the first one, so 139 of those rows belonged to people who already had accounts. Left alone, ordinary returning users would have walked my new counter up to its ceiling and my protection would have started 503ing real signups. It counts user creation timestamps now, which excludes logins by construction.
Tor exit blocking on signup. 1347 addresses, refreshed hourly, failing open if the list cannot be fetched, applied to signup only. Validation was 21 of 21 attack IPs present on the official list, and the one genuine user from that window absent from it.
The same guard on the email OTP route, which is the stronger primitive of the two. It mails anyone you name and creates no user row at all, so abuse through it leaves nothing visible in the admin panel.
Pagination on the admin users page, which is how I found that the page had never been sending a limit or a skip. It had been showing me 50 of 321 users this whole time.
Measured from production logs afterwards: the per IP cap fired zero times. The global ceiling fired three times, all against Tor. The only control that mattered was the one that stopped asking about speed.
Then the cleanup. 64 accounts banned on one objective test, signup IP on the Tor exit list and zero usage of any kind. That came out as 64 bots, 2 humans excluded, and nothing ambiguous. Flags, not deletions, 64 audit rows, and an unban procedure written down before I ran it.

The part people will argue with
Astrika is vibe coded. I have not hand typed most of that codebase, and the popular line is that this makes it indistinguishable from having no security at all.
The defences existed, and they were reasoned about in writing. That comes from having built systems before, and it survives the change in who does the typing.
What the model could not have done for me is the set of judgement calls sitting between those five commits. Not keying the limiter on email. Not turning Turnstile on, because it is a browser widget and the React Native app posts to the same endpoint, so the moment I set that key mobile signup starts failing closed at 403. Checking that Google login had 140 real events on it before adding a guard that could break it. Flagging the 64 instead of deleting them, because in two weeks I might turn out to be wrong about six of them.
None of that is code. It is a series of decisions about who I am willing to lose in order to stop someone, and the machine has no opinion on that question.
Detection is the honest failure here. Three days, and it was my own eyes on a table rather than any alert I had built. There is a Telegram alert on the ceiling now, though I am not sure it would have caught this either, given how slow the whole thing ran.
Which is the bit I keep turning over. Every monitor I own watches for things stopping. Nothing I own watches for things arriving politely, one at a time, each of them perfectly fine on its own.