How I Blocked 50,000 WordPress Login Attacks Per Day with Fail2Ban and Nginx
· 9 min read
A client's 4GB VPS was running hot. Load average was sitting at 6+ on a 2-core machine, and their WooCommerce store was responding in 8-12 seconds instead of the usual sub-2. My first thought was a traffic spike or a misbehaving plugin. It was neither.
What the Access Logs Showed
I SSH'd in and tailed the nginx access log:
tail -f /var/log/nginx/access.log | grep wp-login
The screen filled instantly. Hundreds of POST requests to wp-login.php every minute, from IP addresses scattered across dozens of countries. A quick count confirmed the scale:
grep -c "POST /wp-login.php" /var/log/nginx/access.log
Over 48,000 requests in the last 24 hours. And that was just wp-login.php. I checked xmlrpc.php too:
grep -c "POST /xmlrpc.php" /var/log/nginx/access.log
Another 12,000. Combined, over 60,000 brute force attempts in a single day against one WordPress site.
Each of those requests was hitting PHP-FPM, loading WordPress core, connecting to the database, and running the authentication check. With only 15 PHP-FPM workers available, the legitimate customers were queuing behind a wall of bots trying to guess the admin password.
Why Security Plugins Are Not Enough
Plugins like Wordfence and Limit Login Attempts do work, but they have a fundamental problem: every blocked request still loads WordPress. The PHP process starts, the plugin bootstraps, checks its blocklist, and returns a 403. That entire cycle costs 50-100ms of CPU time and a PHP-FPM worker slot.
When you're absorbing 40+ requests per second, that overhead matters. The fix needs to happen before PHP ever gets involved.
Layer 1: Kill XML-RPC If You Don't Need It
Most WordPress sites have no legitimate use for xmlrpc.php. It was the remote publishing protocol before the REST API existed. Unless your site uses the WordPress mobile app with XML-RPC authentication, Jetpack's legacy connection method, or a third-party integration that specifically requires it, you can block it entirely at the nginx level.
Add this inside your server block:
location = /xmlrpc.php {
access_log off;
log_not_found off;
return 444;
}
The 444 status is an nginx-specific response that drops the connection immediately without sending any response body. It costs effectively zero resources.
After adding this and reloading nginx, 12,000 daily requests disappeared from the PHP-FPM queue instantly:
sudo nginx -t && sudo systemctl reload nginx
Layer 2: Nginx Rate Limiting for wp-login.php
For wp-login.php, we can't just block it outright because legitimate users need to log in. But we can throttle it hard. No human logs in 30 times per minute.
Add a rate limiting zone in the http block of your nginx configuration (usually /etc/nginx/nginx.conf):
# 2 requests per second per IP to login endpoints
limit_req_zone $binary_remote_addr zone=wp_login:10m rate=2r/s;
limit_req_status 429;
Then apply it in your site's server block:
location = /wp-login.php {
limit_req zone=wp_login burst=5 nodelay;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
}
The burst=5 allows a small buffer for legitimate use (loading the page, submitting the form, a redirect). The nodelay flag returns 429 Too Many Requests immediately when the limit is exceeded rather than queuing the request.
This alone cut the PHP-FPM load from bot traffic by roughly 80%. But the bots were still consuming bandwidth and nginx connections. Time for the next layer.
Layer 3: Fail2Ban for Persistent Offenders
Rate limiting slows bots down. Fail2Ban bans them entirely at the firewall level, meaning their packets get dropped before nginx even sees them.
First, install fail2ban if it's not already present:
sudo apt install fail2ban
Create a WordPress Filter
Create /etc/fail2ban/filter.d/wordpress-login.conf:
[Definition]
failregex = ^<HOST> .* "POST /wp-login\.php
ignoreregex =
This matches any POST request to wp-login.php in the nginx access log. Every POST is counted as a "failure" because legitimate users make at most a few POSTs per session, while bots make thousands.
Create the Jail
Create or edit /etc/fail2ban/jail.local:
[wordpress-login]
enabled = true
port = http,https
filter = wordpress-login
logpath = /var/log/nginx/access.log
maxretry = 15
findtime = 60
bantime = 3600
action = iptables-multiport[name=wordpress, port="http,https", protocol=tcp]
This bans any IP that makes more than 15 POST requests to wp-login.php within 60 seconds, for one hour. That's generous enough to never catch a real user (even someone who mistyped their password a few times) but catches every bot I've seen.
Restart fail2ban:
sudo systemctl restart fail2ban
Verify It's Working
Check the jail status:
sudo fail2ban-client status wordpress-login
Within minutes of enabling it on this server, the output showed:
Status for the jail: wordpress-login
|- Filter
| |- Currently failed: 23
| `- Total failed: 1847
`- Actions
|- Currently banned: 14
| Total banned: 87
`- Banned IP list: 45.148.xx.xx 185.234.xx.xx 91.242.xx.xx ...
To manually unban an IP (if you accidentally get locked out):
sudo fail2ban-client set wordpress-login unbanip 1.2.3.4
The Cloudflare Gotcha
If your site sits behind Cloudflare (or any reverse proxy), every request in your access log will show Cloudflare's IP address, not the visitor's real IP. Fail2Ban will then ban Cloudflare's servers, which takes your entire site offline.
Fix this by configuring nginx to extract the real visitor IP from the CF-Connecting-IP header. Add this to your nginx http block:
# Cloudflare IPv4 ranges
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 103.21.244.0/22;
set_real_ip_from 103.22.200.0/22;
set_real_ip_from 103.31.4.0/22;
set_real_ip_from 141.101.64.0/18;
set_real_ip_from 108.162.192.0/18;
set_real_ip_from 190.93.240.0/20;
set_real_ip_from 188.114.96.0/20;
set_real_ip_from 197.234.240.0/22;
set_real_ip_from 198.41.128.0/17;
set_real_ip_from 162.158.0.0/15;
set_real_ip_from 104.16.0.0/13;
set_real_ip_from 104.24.0.0/14;
set_real_ip_from 172.64.0.0/13;
set_real_ip_from 131.0.72.0/22;
real_ip_header CF-Connecting-IP;
After reloading nginx, the access log will show real visitor IPs, and fail2ban will ban the actual attackers instead of Cloudflare's edge servers.
Cloudflare updates these ranges occasionally. I keep a monthly cron job that pulls the latest list from their API and regenerates the config.
Whitelist Your Own IPs
Add your office IP, home IP, and any monitoring services to fail2ban's ignore list in /etc/fail2ban/jail.local:
[DEFAULT]
ignoreip = 127.0.0.1/8 ::1 YOUR.OFFICE.IP YOUR.HOME.IP
This prevents you from locking yourself out if you forget a password or test the login page repeatedly.
The Results
Within 48 hours of deploying all three layers on this server:
- Nginx connections dropped by 40%
- PHP-FPM active workers during peak hours dropped from 14/15 to 6/15
- Average page response time went from 8-12 seconds back to 1.4 seconds
- Fail2Ban was banning 300-400 unique IPs per day
- CPU load average dropped from 6+ to under 1.5
The attacks didn't stop. They never do. Wordfence reports blocking over 6.4 billion brute force attempts across their network every month. Your WordPress site is being probed right now whether you know it or not.
The difference is that now these requests get dropped at the firewall before they consume a single PHP-FPM worker. The site's resources stay available for actual customers.
What I Deploy on Every Server
This three-layer approach (block XML-RPC at nginx, rate-limit wp-login, ban repeat offenders with fail2ban) is now part of my standard server hardening checklist for every WordPress site I manage. It takes about 20 minutes to set up and prevents the single most common source of unexplained slowdowns I see on unmanaged servers.
If your server is running hot and you can't figure out why, check your access logs for wp-login.php and xmlrpc.php before you reach for a bigger instance. You might be paying for CPU that's doing nothing but rejecting bots.
This is one of the first things I configure as part of server management and security monitoring. If your WordPress site is under constant attack and you'd rather not deal with the server-level configuration yourself, check out my maintenance plans.
