WordPress Brute Force Protection with fail2ban: Jail, Filter and Testing
How do I block an IP making 300 login attempts per minute to WordPress in 10 seconds? Step-by-step with fail2ban jail file, custom filter and test commands.

Last week I saw an IP making over 300 login attempts per minute to futia.net. This traffic, which slipped through Cloudflare, had pushed the server's CPU to 80%. WordPress plugins like Wordfence and Sucuri fall short at this point because the attack needs to be blocked before it reaches the PHP level. This is where fail2ban comes in: at the system level, it blocks traffic by adding IPs to iptables before they reach wp-login.php. In this article, we won't skip creating a jail file, writing a custom filter, and testing. The biggest security gap I've seen in 6 years of social media marketing is the lack of server-level protection. 90% of WordPress sites still defend at the application layer, whereas attacks should be stopped the moment they arrive as HTTP requests.
What is fail2ban and Why is it Critical for WordPress?
fail2ban is a Python application that monitors log files and automatically blocks IPs based on specific patterns. It's critical for WordPress because endpoints like wp-login.php and xmlrpc.php are constantly targeted. When a botnet sends thousands of requests per minute, plugins like Wordfence have to process each request at the PHP level. This exponentially increases CPU and RAM consumption.
I received my first brute force attack 3 days after setting up futia.net. In the server logs, there were 5 requests per second from the same IP, all POST /wp-login.php. Wordfence blocked the IP after 10 minutes, but 3,000 requests had been processed in those 10 minutes. After installing fail2ban, the same IP was banned after 5 failed attempts, taking a total of 15 seconds.
The important difference: fail2ban works at the iptables level. This means blocked IPs can't even establish a TCP connection to the server. WordPress, PHP-FPM, Nginx—none of them see this traffic. Even if you're behind Cloudflare, it works as a layer protecting your origin IP.
WordPress's Vulnerable Points
wp-login.php is at the same path on every WordPress site. xmlrpc.php is open by default and thousands of brute force attempts can be made with a single request using the system.multicall method. The wp-json/wp/v2/users endpoint exposes usernames. fail2ban can protect all three points with separate jails.
doktorbul.com has 79,000 doctor profiles and a separate URL for each profile page. While bots browse these pages, they also scan wp-login.php. In the first month, we were seeing 12,000 login attempts per day. After installing fail2ban, this number dropped to 200 because IPs are blocked after the first 3 attempts, bots get blacklisted and never come back.
Creating a Jail File: wordpress-hard.conf
fail2ban's configuration consists of two parts: jail (under what conditions, after how many attempts, for how long to block) and filter (what pattern to search for in the log file). We create the jail file under /etc/fail2ban/jail.d/ because directly editing jail.conf gets overwritten during updates.
I create a file named wordpress-hard.conf:
[wordpress-hard]
enabled = true
port = http,https
filter = wordpress-hard
logpath = /var/log/nginx/access.log
maxretry = 3
findtime = 600
bantime = 3600
action = iptables-multiport[name=wordpress, port="http,https", protocol=tcp]
This configuration says: block an IP that makes 3 failed attempts (maxretry=3) within 10 minutes (findtime=600) for 1 hour (bantime=3600). The logpath part is important: if you're using Nginx, write /var/log/nginx/access.log, if Apache, write /var/log/apache2/access.log.
On futia.net, I use findtime=300 and bantime=86400. So I ban IPs that make 3 attempts in 5 minutes for 24 hours. Because a legitimate user won't enter the wrong password 3 times in 5 minutes—it's definitely bot traffic.
Parameter Details
maxretry: How many failed attempts to allow. 3 is aggressive, 5 is balanced, 10 is loose. I use 3 in production because every request to wp-login.php is already suspicious.
findtime: Time window in seconds. 600 (10 minutes) is standard, but 300 (5 minutes) is better for high-traffic sites. Because bots usually send heavy requests in the first 2-3 minutes.
bantime: Block duration. 3600 (1 hour) is good for starters, but for repeat IPs you can write -1 for permanent ban. Caution: if you use permanent ban and accidentally ban your own IP, you can't access the server.
action: iptables-multiport blocks both ports 80 and 443. If you write iptables-allports, it closes all ports, including SSH. Never do this.
Writing a Filter File: wordpress-hard.conf
The filter defines the regex pattern to search for in log lines. Create the file /etc/fail2ban/filter.d/wordpress-hard.conf:
[Definition]
failregex = ^<HOST> .* "POST /wp-login.php
^<HOST> .* "POST /xmlrpc.php
^<HOST> .* "GET /wp-login.php.*" 200
ignoreregex =
This filter searches for three patterns: 1. POST /wp-login.php: Login attempt 2. POST /xmlrpc.php: xmlrpc brute force 3. GET /wp-login.php with 200 response: Successful login page load (bot scanning)
192.168.1.100 - - [15/Jan/2025:14:32:10 +0000] "POST /wp-login.php HTTP/1.1" 200 1234 "-" "Mozilla/5.0"
The regex extracts 192.168.1.100 from this line. Apache logs can be slightly different, you need to test.
Advanced Filter: 4xx and 5xx Response Codes
Some bots send GET requests to wp-login.php but get 404 (page not found). This also creates server load. Advanced filter:
failregex = ^<HOST> .* "POST /wp-login.php.*" (200|401|403)
^<HOST> .* "POST /xmlrpc.php.*" (200|403|405)
^<HOST> .* "GET /wp-login.php.*" 404
^<HOST> .* "POST .*" 500
Here we're also banning IPs that generate 500 errors because they're usually attempting SQL injection or exploits.
On diolivo.com.tr, while the CartBounty cart recovery automation was running, some bots were trying to fetch usernames by requesting the /wp-json/wp/v2/users endpoint. I added this to the filter:
^<HOST> .* "GET /wp-json/wp/v2/users
6 months later, traffic to this endpoint dropped 95%.
fail2ban Installation and Starting the Service
Ubuntu/Debian:
sudo apt update
sudo apt install fail2ban -y
CentOS/RHEL:
sudo yum install epel-release -y
sudo yum install fail2ban -y
After installation, the service doesn't start automatically, start it manually:
sudo systemctl start fail2ban
sudo systemctl enable fail2ban
Status check:
sudo systemctl status fail2ban
To see active jails:
sudo fail2ban-client status
Output:
Status
|- Number of jail: 1
`- Jail list: wordpress-hard
Details of a specific jail:
sudo fail2ban-client status wordpress-hard
If you see a line like Currently banned: 5, it means 5 IPs are blocked.
Applying Configuration Changes
After editing a jail or filter file, you need to restart fail2ban:
sudo systemctl restart fail2ban
But caution: if you restart, the current ban list may be reset. To only reload the configuration:
sudo fail2ban-client reload
Reload a specific jail:
sudo fail2ban-client reload wordpress-hard
I use reload after every change on futia.net because during restart there's a 2-3 second window where bots can get in.
Testing the Filter: fail2ban-regex
To test whether the filter is working correctly, there's the fail2ban-regex command. Test with your actual log file:
sudo fail2ban-regex /var/log/nginx/access.log /etc/fail2ban/filter.d/wordpress-hard.conf
Output:
Running tests
=============
Use failregex filter file : wordpress-hard, basedir: /etc/fail2ban
Use datepattern : Default Detectors
Use log file : /var/log/nginx/access.log
Use encoding : UTF-8
Results
=======
Failregex: 47 total
|- #) [# of hits] regular expression
| 1) [32] ^<HOST> .* "POST /wp-login.php
| 2) [12] ^<HOST> .* "POST /xmlrpc.php
| 3) [3] ^<HOST> .* "GET /wp-login.php.*" 200
`-
Ignoreregex: 0 total
Date template hits:
|- [# of hits] date format
| [47] Day(?P<_sep>[-/])MON(?P=_sep)ExYear[ :]?24hour:Minute:Second(?:\.Microseconds)?(?: Zone offset)?
`-
Lines: 47 lines, 0 ignored, 47 matched, 0 missed
47 matched means 47 lines in the log file matched the filter. If you see 0 matched, your regex is wrong or the log format is different.
Manual Test: Testing with a Single Line
Copy a line from the log file:
192.168.1.100 - - [15/Jan/2025:14:32:10 +0000] "POST /wp-login.php HTTP/1.1" 200 1234 "-" "Mozilla/5.0"
Save this line to a file (test.log) and test with fail2ban-regex:
echo '192.168.1.100 - - [15/Jan/2025:14:32:10 +0000] "POST /wp-login.php HTTP/1.1" 200 1234 "-" "Mozilla/5.0"' > test.log
sudo fail2ban-regex test.log /etc/fail2ban/filter.d/wordpress-hard.conf --print-all-matched
The --print-all-matched parameter shows which lines matched.
On memuratamalari.com, we pull 50+ job postings daily from the ilan.gov.tr API and create a separate page for each posting. While bots were scanning these pages, they were also trying wp-login.php. When I tested with fail2ban-regex, the GET /wp-login.php pattern wasn't matching at all because in the Nginx access.log it was written as "GET /wp-login.php HTTP/1.1" but there was a missing space in the regex. After fixing it, matching started.
Real-World Scenario: italyanmutfagi.com
italyanmutfagi.com has 618 recipes and each recipe is marked with Schema.org Recipe. The site gets 120,000 visitors per month, 15% of which is bot traffic. In the first 2 months, wp-login.php was receiving 8,000 requests per day. After installing fail2ban:
1. First week: 340 IPs banned 2. Second week: 89 IPs (repeats) 3. Third week: 12 IPs 4. After the fourth week: 1-2 IPs per day
CPU usage dropped from 45% to 22%. PHP-FPM process count went from 80 to 35. Page load time dropped from 1.2 seconds to 0.8 seconds. All of this thanks to fail2ban, because bot traffic was cut at the Nginx level and never reached PHP.
Cloudflare + fail2ban Combination
If you're using Cloudflare, it hides the origin IP but fail2ban should still work. Because requests coming from Cloudflare can still reach wp-login.php. Cloudflare has its own firewall but there's no rate limiting on the free plan. fail2ban fills this gap.
To get the real IP in Nginx:
set_real_ip_from 103.21.244.0/22;
set_real_ip_from 103.22.200.0/22;
(Add Cloudflare IP ranges)
real_ip_header CF-Connecting-IP;
This way fail2ban sees the real visitor IP in the log, not the Cloudflare IP.
Common Errors and Solutions
Error 1: fail2ban service won't start
sudo journalctl -u fail2ban -n 50
Check the output. Usually there's a syntax error in the jail or filter file. Especially escape characters (\) may be missing in the regex.
Error 2: No IPs are being banned
Test with fail2ban-regex. If you see 0 matched:
- Log path is wrong (check logpath=/var/log/nginx/access.log)
- Log format is different (Apache vs Nginx)
- regex pattern is incorrect
Error 3: You banned your own IP
fail2ban-client set wordpress-hard unbanip 192.168.1.100
Permanently whitelist your own IP. Create /etc/fail2ban/jail.local file:
[DEFAULT]
ignoreip = 127.0.0.1/8 ::1 192.168.1.100
Error 4: IP returns before ban time expires
sudo iptables -L -n | grep 192.168.1.100
Check if the IP is in iptables with this command. If it's not there, the fail2ban action part isn't working.
Port and Protocol Issues
Some servers use firewalld instead of iptables. fail2ban should auto-detect this but sometimes you need to change the action:
action = firewallcmd-multiport[name=wordpress, port="http,https", protocol=tcp]
I experienced this error during the initial setup on futia.net. The fail2ban service was running, I was seeing matches in the logs, but IPs weren't being banned. Once I realized I was using firewalld, I changed the action and the problem was solved.
Monitoring and Reporting the Ban List
To see banned IPs:
sudo fail2ban-client status wordpress-hard
The output shows Currently banned: 12 and the IP list. Detailed log:
sudo tail -f /var/log/fail2ban.log
For real-time monitoring. You see ban and unban operations:
2025-01-15 14:35:22,123 fail2ban.actions [12345]: NOTICE [wordpress-hard] Ban 192.168.1.100
2025-01-15 15:35:22,456 fail2ban.actions [12345]: NOTICE [wordpress-hard] Unban 192.168.1.100
I analyze fail2ban.log every week. I see the most banned IPs, what hours have high activity, which endpoints are being targeted. This data is very valuable for optimizing firewall rules.
Visualization with Grafana
Advanced usage: send fail2ban.log to Loki with Promtail, visualize in Grafana. I set this up for futia.net, now on the dashboard:
- Hourly ban count graph
- Most banned IPs (top 10)
- Distribution by jail (wordpress-hard, ssh, nginx-limit)
- Geographic distribution (extracting country info from IP)
This setup took 2 hours but now I see the security status at a glance.
Separate Jail for xmlrpc.php
xmlrpc.php is a special case because thousands of attempts can be made in a single request with the system.multicall method. Create a separate jail and filter:
[xmlrpc-dos]
enabled = true
port = http,https
filter = xmlrpc-dos
logpath = /var/log/nginx/access.log
maxretry = 1
findtime = 60
bantime = 86400
maxretry=1 is notable: even a single request to xmlrpc.php is suspicious, ban immediately. Filter:
[Definition]
failregex = ^<HOST> .* "POST /xmlrpc.php
ignoreregex =
This is an aggressive approach but the likelihood of having a legitimate service using xmlrpc.php is very low. If you're using plugins like Jetpack, xmlrpc may be necessary. In that case, set maxretry=5.
On doktorbul.com, xmlrpc.php is completely closed (we return 403 at the Nginx level) but bots still keep trying. fail2ban bans these IPs too, even unnecessary 403 responses are eliminated.
After this setup, your WordPress site is protected at the server level. Plugins like Wordfence and Sucuri are still important (for application layer protection) but fail2ban provides a critical first line of defense. In my 6 years working in social media marketing, I've seen that clients' biggest problem isn't traffic but security. Because once you're hacked, you lose Google rankings, customer trust is gone.
At FUTIA, when providing site + automation + monthly maintenance service, fail2ban installation is included in every project. Because while automation is running, server performance is critical, and bot traffic disrupts this. If you want to set up WordPress security from scratch on your own server or need support to optimize your existing fail2ban setup, you can reach out via WhatsApp +90 532 491 17 05 or info@futia.net. I work from the Netherlands but respond in sync with Turkey time.
Frequently Asked Questions
Does fail2ban slow down WordPress?
No, quite the opposite—it speeds it up. fail2ban works at the system level (iptables), putting no load on PHP or WordPress. Blocked IPs can't even establish a TCP connection to the server, meaning Nginx or Apache don't see this traffic. After installing fail2ban on futia.net, CPU usage dropped 30% because bot traffic was cut before reaching PHP-FPM. The only cost is fail2ban reading log files, but since this is a few lines per second, it's negligible.
Do I need fail2ban if I use Cloudflare?
Yes, because Cloudflare doesn't offer rate limiting on the free plan and if your origin IP is known, it can be directly targeted. Also, legitimate requests coming from Cloudflare can still brute force wp-login.php. fail2ban works as a layer complementing Cloudflare's firewall. If you set real_ip_header CF-Connecting-IP in Nginx, fail2ban can see the real visitor IP and ban the attacker, not Cloudflare IPs. I use the Cloudflare + fail2ban combination in all my projects.
What should maxretry be in the fail2ban jail file?
3-5 is ideal for wp-login.php. 3 is aggressive but safe, because a legitimate user won't enter the wrong password 3 times in 10 minutes. 5 is more balanced, allowing tolerance for real users who sometimes forget their password. For xmlrpc.php you can set maxretry=1 because every POST request to this endpoint is suspicious. For SSH I recommend maxretry=5, sometimes mistakes happen when typing passwords in the terminal. I use 3 for wp-login and 5 for SSH on my production servers. You can use higher values in test environments.
I'm getting 0 matched when testing fail2ban filter, why?
Three main reasons: 1) Log path is wrong (check if logpath=/var/log/nginx/access.log is correct), 2) Log format doesn't match the regex in the filter (Nginx vs Apache format is different), 3) There's a syntax error in the regex pattern. Do detailed testing with fail2ban-regex /var/log/nginx/access.log /etc/fail2ban/filter.d/wordpress-hard.conf --print-all-matched command. You can copy a line from the log file and test it on regex101.com. Also make sure the <HOST> placeholder is working correctly, in some custom log formats the IP address may be in a different position.
I accidentally banned my own IP, how do I remove it?
You can immediately remove it with sudo fail2ban-client set wordpress-hard unbanip 192.168.1.100 command (write your own IP instead of 192.168.1.100). To permanently add to whitelist, create /etc/fail2ban/jail.local file and add ignoreip = 127.0.0.1/8 ::1 YOUR_IP_ADDRESS to the [DEFAULT] section. If you don't have SSH access to the server and banned your own IP, you can log in from your hosting provider's control panel (cPanel, Plesk) or VPS console and manually remove it with iptables -D INPUT -s 192.168.1.100 -j DROP command.
Want to apply one of the techniques from this post? Fill out a short form and we'll email you a free preview audit within 48 hours.