We blocked 16,847 bots in three days. Here is exactly how.

admin · · 6 min read

We run a WordPress Multisite network. One morning the database was noticeably larger than it had been the night before, and the reason turned out to be four thousand user accounts that no human had created. By the end of the week the mu-plugin we wrote to stop it had logged 16,847 blocked requests in seventy-two hours.

This post is the whole story: what the attack actually is, why the usual advice does not work, the four layers we ended up with, and what the numbers looked like afterwards. If you run Multisite, some version of this is happening to you right now whether you have noticed or not.

What is actually going on

WordPress Multisite ships with a public registration endpoint at /wp-signup.php. Depending on your network settings it will let a visitor create a user account, a site, or both. Even with registration set to none, the file still exists, still loads WordPress, still bootstraps every plugin you have installed, and still returns a page.

That last part is the bit that matters. A bot hitting wp-signup.php is not just a line in your access log. It is a full WordPress bootstrap: database connections, option loading, plugin initialisation, theme setup. On a busy network that is tens of milliseconds of real CPU per request, and the bots do not send one request.

The traffic we saw came in bursts — a few hundred requests over ten minutes, then silence for an hour, then another burst from a different subnet. Classic distributed behaviour, designed specifically to stay under the thresholds that most rate limiters are set to.

Why they bother

Three reasons, in roughly descending order of how common they are:

  • Link placement. A created site is a free page on an established domain, which is worth something to anyone selling SEO backlinks.
  • Account farming. A valid user account on a WordPress install is a foothold, and a foothold is worth having even before anyone knows what to do with it.
  • Probing. Some of it is not targeted at all. It is a crawler checking every domain it can find for an open signup endpoint, and yours answered.

Why the usual advice does not work

Search for this problem and you will find four suggestions. We tried all of them.

“Set registration to none”

We had. Registration was already set to none in network settings. The bots kept coming, because the setting controls whether the form succeeds, not whether the endpoint responds. The file still loads WordPress in full and still returns HTTP 200 with a “registration is disabled” message. From the bot’s point of view the domain is still alive and still worth retrying tomorrow.

“Add a captcha”

A captcha stops the account being created. It does not stop the request, which means it does not stop the load — and it adds a third-party script to a page you did not want served in the first place. You have made your site slower to solve a problem you would rather did not reach PHP at all.

“Install a security plugin”

The big security plugins will block this, and they will do it from inside PHP after WordPress has already loaded. You have swapped an unauthenticated bootstrap for an unauthenticated bootstrap plus a firewall plugin. It works, and it costs you the thing you were trying to save.

“Put Cloudflare in front of it”

This one genuinely helps, and it is part of our final answer. But edge rules only cover traffic that comes through the edge. Anything hitting your origin IP directly — and bots absolutely do scan origin IPs — walks straight past it.

What we actually built

The principle we settled on: reject the request at the earliest layer that can reject it, and repeat that at every layer below, so that no single misconfiguration re-opens the door.

Layer one — the web server

The cheapest possible block. A rule in .htaccess that returns 403 before PHP starts:

<FilesMatch "^(wp-signup|wp-activate).php$">
    Require all denied
</FilesMatch>

This costs microseconds and handles the overwhelming majority of the traffic. It is also the layer people forget to add back after a migration, which is why there are three more below it.

Layer two — a must-use plugin

Must-use plugins load before regular plugins, before the theme, and before most of WordPress’s own initialisation. A short mu-plugin that checks the request URI and exits immediately still costs you a partial bootstrap, but it stops the expensive part — and crucially it works even if the .htaccess rule is missing, which on a shared host it eventually will be.

It also gives you somewhere to count from. Every block increments a counter, which is where the 16,847 figure came from.

Layer three — the firewall

A request that has been blocked twice and comes back a third time is not a confused human. We added ConfigServer Security & Firewall regex rules that watch for the blocked pattern in the access log and ban the source IP outright after a threshold. That moves the block from “per request” to “per attacker”, which is where the real saving is.

The same idea works with fail2ban if that is what you run.

Layer four — the edge

Finally, a Cloudflare expression that never lets the request reach the origin at all. This is the layer that saves bandwidth rather than CPU, and it is the one that stops a large burst from ever becoming your problem.

The numbers

Across the first three days on one network:

  • 16,847 requests blocked.
  • 2,314 unique source IP addresses.
  • Zero legitimate registrations affected — because there were none to affect, and because invited and admin-created users never touch the signup endpoint.
  • Average origin CPU dropped by a margin that was visible on the graph without squinting.

The second network we deployed it to — a smaller one, with a domain nobody would call high-profile — logged just over 3,000 in the same period. The idea that this only happens to big sites is wrong. It happens to every site with the endpoint open, because finding them is automated and free.

How to check your own site right now

Two commands. First, see whether the endpoint answers:

curl -sI https://yoursite.com/wp-signup.php | head -1

If that returns 200 rather than 403, the door is open.

Second, see how much of it you have already been getting:

grep -c "wp-signup.php" /var/log/apache2/access.log

Adjust the log path for your host. The number surprises most people.

We packaged it

All four layers, the counter, and the generated rules for each platform are what became Signup Shield. It installs as a must-use plugin, writes the .htaccess block for you, and hands you copy-paste rules for CSF, ModSecurity and Cloudflare. There is a dashboard widget showing exactly what it has stopped and when, because seeing the number is what convinces people the problem was real.

It is a one-time purchase, it phones home to nobody, and it adds nothing at all to your front-end page weight — which, given the problem it solves, felt like the only defensible way to build it.

Leave a comment

Your email address will not be published. Required fields are marked *

Release notes, once a month

New products, major updates and the occasional deep-dive. No spam, unsubscribe in one click.