{"id":50,"date":"2026-09-20T00:41:42","date_gmt":"2026-09-20T00:41:42","guid":{"rendered":"https:\/\/zyzxsolutions.com\/?p=50"},"modified":"2026-09-20T00:41:42","modified_gmt":"2026-09-20T00:41:42","slug":"we-blocked-16847-bots-in-three-days-here-is-exactly-how","status":"publish","type":"post","link":"https:\/\/zyzxsolutions.com\/?p=50","title":{"rendered":"We blocked 16,847 bots in three days. Here is exactly how."},"content":{"rendered":"<p>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 <strong>16,847 blocked requests in seventy-two hours<\/strong>.<\/p>\n<p>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.<\/p>\n<h2>What is actually going on<\/h2>\n<p>WordPress Multisite ships with a public registration endpoint at <code>\/wp-signup.php<\/code>. Depending on your network settings it will let a visitor create a user account, a site, or both. Even with registration set to <em>none<\/em>, the file still exists, still loads WordPress, still bootstraps every plugin you have installed, and still returns a page.<\/p>\n<p>That last part is the bit that matters. A bot hitting <code>wp-signup.php<\/code> 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.<\/p>\n<p>The traffic we saw came in bursts \u2014 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.<\/p>\n<h3>Why they bother<\/h3>\n<p>Three reasons, in roughly descending order of how common they are:<\/p>\n<ul>\n<li><strong>Link placement.<\/strong> A created site is a free page on an established domain, which is worth something to anyone selling SEO backlinks.<\/li>\n<li><strong>Account farming.<\/strong> 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.<\/li>\n<li><strong>Probing.<\/strong> 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.<\/li>\n<\/ul>\n<h2>Why the usual advice does not work<\/h2>\n<p>Search for this problem and you will find four suggestions. We tried all of them.<\/p>\n<h3>&#8220;Set registration to none&#8221;<\/h3>\n<p>We had. Registration was already set to <code>none<\/code> in network settings. The bots kept coming, because the setting controls whether the form <em>succeeds<\/em>, not whether the endpoint <em>responds<\/em>. The file still loads WordPress in full and still returns HTTP 200 with a &#8220;registration is disabled&#8221; message. From the bot&#8217;s point of view the domain is still alive and still worth retrying tomorrow.<\/p>\n<h3>&#8220;Add a captcha&#8221;<\/h3>\n<p>A captcha stops the account being created. It does not stop the request, which means it does not stop the load \u2014 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.<\/p>\n<h3>&#8220;Install a security plugin&#8221;<\/h3>\n<p>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.<\/p>\n<h3>&#8220;Put Cloudflare in front of it&#8221;<\/h3>\n<p>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 \u2014 and bots absolutely do scan origin IPs \u2014 walks straight past it.<\/p>\n<h2>What we actually built<\/h2>\n<p>The principle we settled on: <strong>reject the request at the earliest layer that can reject it<\/strong>, and repeat that at every layer below, so that no single misconfiguration re-opens the door.<\/p>\n<h3>Layer one \u2014 the web server<\/h3>\n<p>The cheapest possible block. A rule in <code>.htaccess<\/code> that returns 403 before PHP starts:<\/p>\n<pre><code>&lt;FilesMatch \"^(wp-signup|wp-activate).php$\"&gt;\n    Require all denied\n&lt;\/FilesMatch&gt;<\/code><\/pre>\n<p>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.<\/p>\n<h3>Layer two \u2014 a must-use plugin<\/h3>\n<p>Must-use plugins load before regular plugins, before the theme, and before most of WordPress&#8217;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 \u2014 and crucially it works even if the <code>.htaccess<\/code> rule is missing, which on a shared host it eventually will be.<\/p>\n<p>It also gives you somewhere to count from. Every block increments a counter, which is where the 16,847 figure came from.<\/p>\n<h3>Layer three \u2014 the firewall<\/h3>\n<p>A request that has been blocked twice and comes back a third time is not a confused human. We added ConfigServer Security &amp; 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 &#8220;per request&#8221; to &#8220;per attacker&#8221;, which is where the real saving is.<\/p>\n<p>The same idea works with fail2ban if that is what you run.<\/p>\n<h3>Layer four \u2014 the edge<\/h3>\n<p>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.<\/p>\n<h2>The numbers<\/h2>\n<p>Across the first three days on one network:<\/p>\n<ul>\n<li><strong>16,847<\/strong> requests blocked.<\/li>\n<li><strong>2,314<\/strong> unique source IP addresses.<\/li>\n<li><strong>Zero<\/strong> legitimate registrations affected \u2014 because there were none to affect, and because invited and admin-created users never touch the signup endpoint.<\/li>\n<li>Average origin CPU dropped by a margin that was visible on the graph without squinting.<\/li>\n<\/ul>\n<p>The second network we deployed it to \u2014 a smaller one, with a domain nobody would call high-profile \u2014 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.<\/p>\n<h2>How to check your own site right now<\/h2>\n<p>Two commands. First, see whether the endpoint answers:<\/p>\n<pre><code>curl -sI https:\/\/yoursite.com\/wp-signup.php | head -1<\/code><\/pre>\n<p>If that returns <code>200<\/code> rather than <code>403<\/code>, the door is open.<\/p>\n<p>Second, see how much of it you have already been getting:<\/p>\n<pre><code>grep -c \"wp-signup.php\" \/var\/log\/apache2\/access.log<\/code><\/pre>\n<p>Adjust the log path for your host. The number surprises most people.<\/p>\n<h2>We packaged it<\/h2>\n<p>All four layers, the counter, and the generated rules for each platform are what became <strong>Signup Shield<\/strong>. It installs as a must-use plugin, writes the <code>.htaccess<\/code> 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.<\/p>\n<p>It is a one-time purchase, it phones home to nobody, and it adds nothing at all to your front-end page weight \u2014 which, given the problem it solves, felt like the only defensible way to build it.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>A WordPress Multisite network was quietly drowning in signup bots. This is the anatomy of the attack, why the standard advice does not work, and the four-layer block that stopped it.<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[61,1,62],"tags":[65,63,67,64,66],"class_list":["post-50","post","type-post","status-publish","format-standard","hentry","category-security","category-uncategorized","category-wordpress","tag-bots","tag-multisite","tag-performance","tag-security","tag-wp-signup"],"_links":{"self":[{"href":"https:\/\/zyzxsolutions.com\/index.php?rest_route=\/wp\/v2\/posts\/50","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/zyzxsolutions.com\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/zyzxsolutions.com\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/zyzxsolutions.com\/index.php?rest_route=\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/zyzxsolutions.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=50"}],"version-history":[{"count":0,"href":"https:\/\/zyzxsolutions.com\/index.php?rest_route=\/wp\/v2\/posts\/50\/revisions"}],"wp:attachment":[{"href":"https:\/\/zyzxsolutions.com\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=50"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/zyzxsolutions.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=50"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/zyzxsolutions.com\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=50"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}