Stop WordPress Brute-Force Logins with a Fail2Ban Jail
Your WordPress login page is getting hammered - hundreds or thousands of POST requests to wp-login.php a day, your security plugin's lockout log is overflowing, and the site feels sluggish even though nobody's actually logged in. A login-limiter plugin helps, but it still lets every one of those requests reach PHP-FPM before it says no. If you're on a VPS, the real fix is blocking the attacker at the firewall with Fail2Ban - before WordPress ever wakes up to handle the request.
Symptom: wp-login.php is getting flooded
You'll usually spot this one of three ways:
- Your access log shows the same handful of IPs (or a rotating botnet) hitting
/wp-login.phpor/xmlrpc.phpevery few seconds, all day long - A plugin like Wordfence or Limit Login Attempts Reloaded is logging dozens of lockouts an hour, but new attempts keep coming from different IPs
- PHP-FPM or MySQL CPU usage stays elevated with no real traffic to explain it, and
topshowsphp-fpmworkers constantly busy
A WordPress-side plugin can lock out a username or an IP, but the request still has to boot WordPress, load the database, and run the plugin's own check first. On a small VPS, a few hundred requests a minute is enough to push load averages up and slow the site for real visitors. Blocking at the firewall with Fail2Ban stops the connection before Nginx or Apache even hands it to PHP.
Cause: WordPress logins don't log failures the way SSH does
If you've already set up Fail2Ban for SSH, you might assume you can point it at wp-login.php the same way. There's a catch: SSH logs a distinct "Failed password" line for every bad attempt, but WordPress's default login page returns an HTTP 200 OK for both a failed and a successful login - the difference is only in the HTML body, not the status code. A naive filter that matches any POST /wp-login.php request will happily ban your own IP the third time you fat-finger your password.
There are two workable approaches:
- Rate-limit the endpoint - ban any IP that hits
wp-login.phporxmlrpc.phpmore times than a real human would in a short window. Blunt, but effective against bot floods, and it needs nothing beyond your existing web server log. - Log real failures - add a small must-use plugin that writes an entry only when WordPress actually rejects a login, then point Fail2Ban at that file. More precise, and it won't touch anyone who just mistypes a password once.
We'll set up the second one, since it's what most support tickets end up needing once the blunt version starts catching real users.
Fix: build a custom Fail2Ban jail for WordPress logins
Step 1: Confirm Fail2Ban is installed
sudo systemctl status fail2ban
fail2ban-client version
If it's not installed yet, on Ubuntu/Debian:
sudo apt update && sudo apt install fail2ban -y
sudo systemctl enable --now fail2ban
Step 2: Log failed logins to their own file
Create a must-use plugin so it loads automatically without needing activation:
sudo mkdir -p /var/www/yoursite/wp-content/mu-plugins
sudo nano /var/www/yoursite/wp-content/mu-plugins/log-failed-logins.php
<?php
// Logs failed WordPress login attempts for Fail2Ban to watch.
add_action( 'wp_login_failed', function ( $username ) {
$ip = isset( $_SERVER['REMOTE_ADDR'] ) ? filter_var( $_SERVER['REMOTE_ADDR'], FILTER_VALIDATE_IP ) : false;
$user = sanitize_text_field( $username );
if ( ! $ip ) {
return;
}
$line = sprintf( '[%s] Failed login for user "%s" from %s%s', gmdate( 'Y-m-d H:i:s' ), $user, $ip, PHP_EOL );
error_log( $line, 3, '/var/log/wp-login-fail.log' );
});
Create the log file and let the web server user write to it:
sudo touch /var/log/wp-login-fail.log
sudo chown www-data:www-data /var/log/wp-login-fail.log
sudo chmod 640 /var/log/wp-login-fail.log
Swap www-data for whatever user your PHP-FPM pool runs as (check ps aux | grep php-fpm if you're not sure).
Step 3: Write the Fail2Ban filter
sudo nano /etc/fail2ban/filter.d/wordpress-auth.conf
[Definition]
failregex = Failed login for user ".*" from <HOST>
ignoreregex =
Step 4: Create the jail
sudo nano /etc/fail2ban/jail.d/wordpress-auth.conf
[wordpress-auth]
enabled = true
filter = wordpress-auth
logpath = /var/log/wp-login-fail.log
port = http,https
maxretry = 5
findtime = 10m
bantime = 1h
action = %(action_)s
| Setting | What it controls |
|---|---|
maxretry | How many failed logins from one IP before it's banned |
findtime | The window those failures have to happen in to count |
bantime | How long the IP stays blocked - raise it for repeat offenders |
Step 5: Test the filter before trusting it
Fail2Ban can dry-run a filter against your log without banning anyone, which saves you from finding out about a typo the hard way:
sudo fail2ban-regex /var/log/wp-login-fail.log /etc/fail2ban/filter.d/wordpress-auth.conf
You want to see "Success" with a match count greater than zero if you've already got some failed attempts logged. If it shows zero matches, double-check the log file actually has entries and that the regex lines up with the format from Step 2.
Step 6: Restart and verify
sudo systemctl restart fail2ban
sudo fail2ban-client status wordpress-auth
That last command shows current bans and total failed attempts for the jail, so you can confirm it's actually catching traffic over the next hour or two.
Whitelist yourself first
Before this goes live, add your own IP (and your team's, and your office's static IP if you have one) to the global ignore list so a bad password on your end doesn't lock you out of your own site:
sudo nano /etc/fail2ban/jail.local
[DEFAULT]
ignoreip = 127.0.0.1/8 YOUR.IP.ADDRESS.HERE
If you do get banned mid-setup, unban yourself with sudo fail2ban-client set wordpress-auth unbanip YOUR.IP.ADDRESS.HERE.
What if you're on shared cPanel hosting instead of a VPS?
You won't have root access to install Fail2Ban on shared hosting, and you don't need to - cPanel's built-in cPHulk Brute Force Protection handles this at the account level already. The setup above is specifically for VPS and dedicated servers where you manage the firewall yourself.
Prevention: don't rely on one layer
- Turn on two-factor authentication for every admin account, so a leaked or guessed password alone isn't enough to get in
- Disable XML-RPC if you don't use the WordPress mobile app or Jetpack, since
xmlrpc.phpis a common brute-force shortcut around the normal login form - Keep a login-limiter plugin as a second layer, not your only one - it's a safety net for the handful of requests that slip past the firewall before a ban kicks in
- Check
fail2ban-client status wordpress-authmonthly to see if ban volume is climbing, which is often the first sign a site is being specifically targeted rather than hit by generic bot traffic
None of these replace each other. The firewall jail keeps the noise off your server; 2FA and disabling XML-RPC close off the paths an attacker would use if they ever got past it.
Frequently asked questions
Why did Fail2Ban ban my own IP after I mistyped my password once?
That happens when the jail's filter matches every POST to wp-login.php instead of actual failures, since WordPress returns HTTP 200 for both successful and failed logins. Use the mu-plugin approach in this guide, which logs only real failures via the wp_login_failed hook, and add your own IP to ignoreip in jail.local before you test it.
Will this work on shared cPanel hosting?
No, Fail2Ban needs root access to manage the server firewall, which shared hosting doesn't give you. On cPanel shared hosting, cPHulk Brute Force Protection already handles this at the account level - you don't need to set anything up.
Should I use Fail2Ban instead of a WordPress security plugin?
Use both. Fail2Ban blocks the connection at the firewall before WordPress loads, which is lighter on server resources, while a plugin like Wordfence adds application-level checks (2FA, malware scanning) that a firewall jail can't do.
How do I know the jail is actually catching attacks?
Run sudo fail2ban-client status wordpress-auth. It shows the total number of failed attempts and currently banned IPs for that jail, so you can watch the count grow over the next few hours instead of guessing.