Guides

Protect WordPress with fail2ban

Bots hammer wp-login.php and xmlrpc.php on every WordPress site. You can ban them with fail2ban in two ways: with the WP fail2ban plugin, which logs precise events, or with a plugin-free filter on your web server's access log.

Updated 6 min readChecked against fail2ban 1.1.1

To protect WordPress with fail2ban, either install the WP fail2ban plugin, copy its wordpress-hard.conf and wordpress-soft.conf filters to /etc/fail2ban/filter.d/ and enable two jails reading /var/log/auth.log, or — without a plugin — add a filter that counts POST /wp-login.php requests answered with 200 (a failed login; a successful one redirects with 302) and POST /xmlrpc.php requests in your nginx or Apache access log.

/etc/fail2ban/jail.d/wordpress.local
[wordpress-hard]
enabled  = true
backend  = auto
filter   = wordpress-hard
logpath  = /var/log/auth.log
port     = http,https
maxretry = 1

[wordpress-soft]
enabled  = true
backend  = auto
filter   = wordpress-soft
logpath  = /var/log/auth.log
port     = http,https
maxretry = 3

fail2ban itself ships no WordPress filter; the two filters above come with the plugin. The plugin-free alternative is further down.

Option 1: the WP fail2ban plugin#

WP fail2ban – Advanced Security (plugin slug wp-fail2ban, by Charles Lecklider) writes WordPress security events to syslog: failed logins, logins for unknown users, XML-RPC authentication attempts, blocked user enumeration, pingbacks and more. Version 5.5.0 was current when we checked the plugin directory. Install it from the dashboard or with WP-CLI:

bash
wp plugin install wp-fail2ban --activate

Copy the filters#

The plugin ships its filters in its filters.d directory. Copy them into fail2ban:

bash
sudo cp /var/www/html/wp-content/plugins/wp-fail2ban/filters.d/wordpress-hard.conf \
        /var/www/html/wp-content/plugins/wp-fail2ban/filters.d/wordpress-soft.conf \
        /etc/fail2ban/filter.d/
Filter Example events Suggested maxretry
wordpress-hard Authentication attempt for an unknown user via XML-RPC or REST, XML-RPC multicall failures, blocked user enumeration, pingback errors 1
wordpress-soft Wrong password for an existing user, empty username, login attempt for an unknown user 3–5

The SVN copy of the plugin also contains a wordpress-extra.conf for password-reset requests and comments; the plugin's own readme documents only the hard and soft filters, so start with those. Recopy the filters after plugin upgrades — the readme notes when they change.

Where the plugin logs#

The plugin logs through PHP's syslog() with the identifier wordpress to the auth facility. With rsyslog, that ends up in /var/log/auth.log (Debian/Ubuntu) or /var/log/secure (RHEL/Fedora):

log
Oct 25 18:36:10 web1 wordpress[12345]: Authentication failure for admin from 203.0.113.42

Both filters use fail2ban's standard syslog prefix, so this line matches wordpress-soft; we verified it with fail2ban-regex, as well as a wordpress-hard line tagged wordpress(example.com)[12345], which is the form the plugin uses when its WP_FAIL2BAN_SYSLOG_TAG_HOST option is on.

On systems without rsyslog (Debian 12 and later, many minimal images), the messages only exist in the systemd journal. Read them there:

/etc/fail2ban/jail.d/wordpress.local
[wordpress-soft]
enabled      = true
backend      = systemd
journalmatch = SYSLOG_IDENTIFIER=wordpress
filter       = wordpress-soft
port         = http,https
maxretry     = 3

Do the same for wordpress-hard. Check that the journal has the entries with journalctl SYSLOG_IDENTIFIER=wordpress -n 5.

WordPress behind a proxy or CDN#

If WordPress sits behind a reverse proxy, a load balancer or Cloudflare, PHP sees the proxy's address, and the plugin logs that. Tell the plugin which proxies to trust in wp-config.php:

wp-config.php
define('WP_FAIL2BAN_PROXIES', ['10.0.0.5', '192.168.1.0/24']);

The plugin then takes the client from X-Forwarded-For — it deliberately uses only that header. Behind Cloudflare, list Cloudflare's ranges and also ban at Cloudflare's edge, because a local ban doesn't stop traffic arriving through Cloudflare: see the Cloudflare guide.

Option 2: plugin-free filter on the access log#

WordPress answers a failed login form submission with 200 (the form is shown again with an error) and a successful one with a 302 redirect. Counting POST /wp-login.php … 200 in the access log therefore counts failed logins. xmlrpc.php is the other brute-force target: one request can try many passwords via system.multicall, so any POST to it from a stranger is suspicious.

/etc/fail2ban/filter.d/wordpress-login.conf
[Definition]
failregex = ^<HOST> \S+ \S+ \[\] "POST /wp-login\.php[^"]*" 200\s
            ^<HOST> \S+ \S+ \[\] "POST /xmlrpc\.php[^"]*" \d+\s
datepattern = ^[^\[]*\[({DATE})
              {^LN-BEG}

It reads the combined log format that nginx and Apache use by default:

log
203.0.113.42 - - [25/Oct/2026:18:36:10 +0000] "POST /wp-login.php HTTP/1.1" 200 4512 "https://example.com/wp-login.php" "Mozilla/5.0"
192.0.2.15 - - [25/Oct/2026:18:36:12 +0000] "POST /xmlrpc.php HTTP/1.1" 200 403 "-" "Mozilla/5.0"

Tested with fail2ban-regex: both lines match, while a POST /wp-login.php … 302 (successful login) and a GET /wp-login.php … 200 (just opening the page) don't.

Jail for nginx#

/etc/fail2ban/jail.d/wordpress.local
[wordpress-login]
enabled  = true
backend  = auto
filter   = wordpress-login
logpath  = /var/log/nginx/access.log
port     = http,https
maxretry = 5
findtime = 10m
bantime  = 1h

Jail for Apache#

/etc/fail2ban/jail.d/wordpress.local
[wordpress-login]
enabled  = true
backend  = auto
filter   = wordpress-login
logpath  = %(apache_access_log)s
port     = http,https
maxretry = 5
findtime = 10m
bantime  = 1h

backend = auto is needed on Ubuntu 24.04, whose package defaults every jail to the systemd backend, which ignores logpath. If each site has its own access log, list the files in logpath or use a glob. Pick maxretry with real users in mind: someone mistyping a password three times shouldn't be banned for an hour; see bantime, findtime and maxretry.

Limitations of the plugin-free method: password-reset submissions (wp-login.php?action=lostpassword) with a typo also return 200, and security plugins that change the login URL move the target. The plugin knows what actually happened; the access log only knows status codes.

Block xmlrpc.php if you don't need it#

Most sites don't use XML-RPC (the mobile apps and Jetpack can). If you don't, deny it at the web server and let fail2ban ban those who keep trying.

nginx server block
location = /xmlrpc.php {
    deny all;
}
Apache vhost or .htaccess
<Files "xmlrpc.php">
    Require all denied
</Files>

With nginx, denied requests produce access forbidden by rule in the error log, which the stock nginx-forbidden filter bans — enable it as shown in the nginx guide. With Apache, they show up as client denied by server configuration, which apache-auth counts (see the Apache guide).

Test it#

bash
# plugin route
sudo fail2ban-regex /var/log/auth.log /etc/fail2ban/filter.d/wordpress-soft.conf

# plugin-free route
sudo fail2ban-regex /var/log/nginx/access.log /etc/fail2ban/filter.d/wordpress-login.conf

sudo fail2ban-client status wordpress-soft

Then log in with a wrong password a few times from a phone on mobile data and watch sudo tail -f /var/log/fail2ban.log for the Ban line. Unban yourself with sudo fail2ban-client unban <IP>.

FAQ#

Does fail2ban have a WordPress filter?#

No. fail2ban 1.1.1 doesn't ship one. The WP fail2ban plugin provides wordpress-hard.conf and wordpress-soft.conf, which match the events it logs to syslog. Without the plugin, write a filter that counts POST /wp-login.php requests with status 200 and POST /xmlrpc.php requests in the access log.

How does fail2ban detect failed WordPress logins without a plugin?#

WordPress redisplays the login form with status 200 when a login fails and redirects with 302 when it succeeds. A filter matching "POST /wp-login.php … " 200 in the nginx or Apache access log therefore counts failed attempts. Successful logins and simple page views of the login form aren't counted.

Should I ban xmlrpc.php requests?#

If you don't use XML-RPC (WordPress mobile apps, Jetpack, some publishing tools), yes: block xmlrpc.php in the web server and ban repeat offenders. Attackers use system.multicall to try many passwords in a single request, so a low maxretry is appropriate for POSTs to it.

Where does the WP fail2ban plugin write its log?#

To syslog with the identifier wordpress and the auth facility, so normally /var/log/auth.log on Debian/Ubuntu or /var/log/secure on RHEL. Without rsyslog it's only in the systemd journal; use backend = systemd and journalmatch = SYSLOG_IDENTIFIER=wordpress in the jails.

Why does the WP fail2ban plugin log my proxy's IP?#

Because PHP sees the connection from the reverse proxy or CDN. Define WP_FAIL2BAN_PROXIES in wp-config.php with the proxies' IPs or CIDR ranges; the plugin then reads the client address from X-Forwarded-For. Untrusted X-Forwarded-For headers are logged as a hard failure instead.

Spotted something wrong or out of date? Tell us — every page is checked against the current fail2ban release.