043 —Security
wp-login.php brute force: the .htaccess block that works
The bots have been hammering wp-login.php every two seconds for three days. Here is the .htaccess block that stops them and the reason it does not break the dashboard.
You SSH in to find out why the database load on a client's WordPress site is spiking. tail -f /var/log/apache2/access.log, and there it is: POST /wp-login.php, POST /wp-login.php, POST /wp-login.php, every two to four seconds, each line from a different IP. You have seen this pattern a thousand times. You will see it a thousand more.
This post is the .htaccess block we paste into a legacy site the moment we see that pattern, plus a short walkthrough of why it does not break the dashboard, and what it deliberately does not catch.
The shape of the traffic
A modern WordPress brute force run is rarely one IP hammering one endpoint. It is distributed credential-stuffing. A few hundred IPs, often residential proxies, each making one or two POST requests with a guessed password. They scrape usernames from the author archive (/author/admin/, /author/editor/), they POST to wp-login.php and xmlrpc.php, and they grind until they get a 200 or until you stop them.
Confirm the shape with one line:
awk '$7 == "/wp-login.php" && $9 != 200 {print $1}' access.log \
| sort | uniq -c | sort -rn | head -20
You will almost always see the same picture: the top twenty IPs each have between two and twelve failed POSTs. No single IP crosses a fail2ban threshold of, say, ten in five minutes. The aggregate volume is what is melting your database connections.
The block
The point is to refuse the request at the Apache layer, before PHP, before WordPress, before the database connection opens. The cheapest possible rejection.
# /var/www/example.com/.htaccess
# Block POST to wp-login.php unless the referer is this site itself.
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{REQUEST_METHOD} =POST
RewriteCond %{REQUEST_URI} ^/wp-login\.php$
RewriteCond %{HTTP_REFERER} !^https?://([^.]+\.)?example\.com/ [NC]
RewriteRule ^ - [F,L]
</IfModule>
# Block xmlrpc.php entirely. If you actually use it (Jetpack, mobile
# app), delete this block and lock it to specific IPs instead.
<Files xmlrpc.php>
Require all denied
</Files>
Replace example\.com with your real domain, and keep the escaped dot. The block does four things:
- A POST to
wp-login.phpwith no referer, or a foreign referer, returns 403 immediately. - A POST to
wp-login.phpcoming from your own domain passes through untouched. - A GET to
wp-login.php(the form load) is never affected. xmlrpc.phpreturns 403 for everyone.
The reason it catches roughly nine in ten brute force POSTs is mundane. Volume scanners do not bother to spoof a per-site referer. They either send nothing, or they leak their own scanning origin. The handful of bots written specifically against your site will get through. That is fine. They are a different problem with a different fix.
Why the dashboard keeps working
Three things would normally break when you bolt an aggressive Apache rule onto a WordPress install. None of them do here.
The login form itself. A real login is a GET /wp-login.php (the form renders), then a POST /wp-login.php (the credentials submit). The referer on the POST is https://example.com/wp-login.php. Our condition reads "deny unless the referer host matches our domain", so a real submit passes.
admin-ajax.php. The dashboard heartbeat, the block editor's autosave, and almost every plugin's AJAX call live at /wp-admin/admin-ajax.php. That path never matches our rule. Untouched.
Password reset. Lost-password submits a POST to /wp-login.php?action=lostpassword from your own form page. Same referer. Still works.
What does break, the moment you save the file:
- Anything that POSTs to
wp-login.phpprogrammatically without a referer. ManageWP-style remote login, some uptime monitors, a handful of MU plugins. Add their IP above the rule with aRewriteCond %{REMOTE_ADDR} !^203\.0\.113\.42$, or move them to application passwords. - XML-RPC clients (the WordPress mobile app, Jetpack, old desktop editors). If you use any of these, do not blanket-block
xmlrpc.php. Limit it to the specific IPs that need it.
What the block does not catch
Be honest about scope. This rule stops cheap, loud, referer-less credential stuffing. It does not stop:
- A bot written specifically against your site that sets a matching referer. Trivial to do, rare in volume runs, because referer spoofing breaks the operator's own analytics on his own scan.
- Username enumeration via
/?author=Nor the REST API at/wp-json/wp/v2/users. Lock those down separately. The OWASP WordPress guideline has a short section on it. - Compromised credentials from a paste dump. If the password is correct on the first try, the bot looks identical to a real user. Two-factor is the only real answer there.
In rough order of effort, the next things to add once the .htaccess is live are: a redirect on /?author=, two-factor on every account with manage_options, and a non-default login URL for noise reduction. The last one is not security, but it cuts log volume by another order of magnitude.
Auditing the change
Once the block is live, leave a tail -f on the access log for an hour and watch three things:
- Brute force POSTs returning 403 immediately, with no PHP invocation. Confirm by checking that the response time on those lines stays under 5 ms.
- Your own logins returning 302 to
/wp-admin/. - admin-ajax.php traffic unchanged in shape and volume.
If all three hold for an hour, leave the block in. Anything that is going to break on this rule breaks inside the first ten minutes.
Treating server files like code
When we built Pier we kept running into the same scene: the agency that inherited an old WordPress install has no idea which IPs the previous freelancer needed allowlisted, no inventory of which AJAX endpoints break under which Apache rule, and no easy way to roll back when a paste breaks the dashboard at 18:40 on a Friday. The way we ended up handling it was to treat every .htaccess edit the way you would treat a database migration: the file is snapshotted into version history before the save commits, and an undo restores the previous version with a single keystroke. Same workflow for wp-config.php and any other server file you would otherwise be nervous about touching over SFTP.
The smallest thing you can do today is run the awk one-liner above against your own access log. If the top twenty IPs hitting wp-login.php each have a hit count between two and twelve, you are looking at exactly the traffic this block was written for, and you can be done with it in five minutes.
— Questions —
Will this lock me out of my own wp-admin?
No, as long as your browser sends a same-origin referer on the login POST, which it does by default. If a privacy extension strips it, set Referrer-Policy: same-origin in the same .htaccess.
Does this replace fail2ban or a WAF?
No. It catches cheap, referer-less volume at the Apache layer. Targeted bots and credential reuse still need two-factor and rate limiting at a higher layer.
What is the NGINX equivalent?
A location = /wp-login.php block with an if ($http_referer !~* "^https?://([^.]+\.)?example\.com/") { return 403; } inside it. Same logic, different syntax.
Should I always block xmlrpc.php?
Yes, unless you actively use Jetpack, the WordPress mobile app, or an old desktop blog editor. In those cases, allowlist the specific IPs instead of opening it to the world.