Security Shipped

fail2ban for wp-login.php on Trellis: Turn the Jail On, Anchor the Filter, Keep an Nginx Deny List for the Rest

One IP address tried to log in to /wp-login.php 1,420 times in two months on a production Trellis server. Two more tried 860 and 742 times, and over 40 other addresses joined in. None got in. Every attempt still ran PHP and hit the database, and one weak password would have been enough.

Trellis installs fail2ban and protects SSH out of the box. The WordPress jails ship disabled. This is how I turned the login jail on, the filter bug that banned me, and what I use when fail2ban is not the right tool.

Enable the jail

The jails live in trellis/group_vars/all/security.yml:

fail2ban_bantime: 900        # 15 minutes
fail2ban_maxretry: 3         # global default, used by the sshd jail
fail2ban_findtime: 600       # window the failures are counted in

fail2ban_services_custom:
  - name: wordpress_wp_login
    filter: wordpress-wp-login
    enabled: "true"
    port: http,https
    logpath: "/**/logs/access.log"
    maxretry: 6                # more forgiving for real users than sshd

A per-jail maxretry overrides the global one, so SSH stays strict while the login page tolerates a few typos. Apply only the fail2ban role:

cd trellis
trellis provision --tags fail2ban production

Re-run it after any change to security.yml. The first attacker was banned within seven hours.

Skip the wordpress_xmlrpc jail if Nginx already refuses XML-RPC. Run curl -I https://example.com/xmlrpc.php: a 403 or an empty 444 means there is nothing left for that jail to catch.

The filter that banned me

My first filter matched wp-login.php anywhere in the log line. That includes the referrer field, so a logged-in user clicking around right after logging in collected “failures” from the page they came from. I work from changing locations and VPN exits, and I got banned while using the site.

A failed login returns 200 (the form is shown again). A successful one redirects with 302. So match a POST to wp-login.php that returned 200, anchored to the quoted request field:

# filter.d/wordpress-wp-login.conf
[Definition]
failregex = ^<HOST> .* "POST [^"]*wp-login\.php[^"]*" 200

Test any filter against a real log before you trust it. Look for a non-zero matched count:

sudo fail2ban-regex /srv/www/example.com/logs/access.log \
  /etc/fail2ban/filter.d/wordpress-wp-login.conf

Keep an exemption list as a safety valve. ip_whitelist feeds fail2ban’s ignoreip:

ip_whitelist:
  - 127.0.0.0/8
  - ""   # the machine you provision from
  - 203.0.113.0/24                           # office network

I still exempt a VPN range, because one ban landed during a two-factor reset. An exemption list does not replace a correct filter.

Check it, ban, unban

The admin user’s sudo asks for a password, so use ssh -t:

ssh -t admin@example.com "sudo fail2ban-client status wordpress_wp_login"
ssh -t admin@example.com "sudo grep 'Ban ' /var/log/fail2ban.log | tail -20"
sudo fail2ban-client set wordpress_wp_login banip 1.2.3.4
sudo fail2ban-client set wordpress_wp_login unbanip 1.2.3.4

If you lock yourself out, unban from another connection (phone hotspot, VPN). Failing that, use the provider’s server console: sudo systemctl stop fail2ban, log in, add your address to ip_whitelist, re-provision, then start fail2ban again.

When fail2ban is not enough: a permanent Nginx deny list

fail2ban bans temporarily and reads the log after the fact. For a known persistent attacker, or a flood too fast for it, or abuse on a URL the filter does not know about, block at Nginx. Create trellis/nginx-includes/all/deny-ips.conf.j2:

# Managed via trellis/nginx-includes/all/deny-ips.conf.j2
# Last updated: 
# Apply: trellis provision --tags nginx-includes production

deny 62.60.130.228;   # 1,420 wp-login attempts
deny 141.98.11.120;   # 860 wp-login attempts
deny 91.224.92.114;   # 742 wp-login attempts

Apply it with trellis provision --tags nginx-includes production. Not --tags nginx: the includes are templated by the wordpress-setup role, so that run finishes without deploying the file. I found this out when a redirect sat undeployed until wordpress-setup ran. The file lives in Git, survives reboots and is checked early in request handling. The costs are manual upkeep, the risk of blocking yourself, and attackers rotating addresses anyway. Research an address with whois before you add it.

Dry-run first:

ansible-playbook server.yml -e env=production --tags nginx-includes --check

Testing a deny rule

You cannot test it by spoofing X-Forwarded-For. Nginx’s deny matches the connection’s real address ($remote_addr), so the header does nothing unless you have configured real-IP handling for a proxy in front. Two checks that do work:

# is the rule in the live config?
ssh -t admin@example.com "sudo nginx -T 2>/dev/null | grep -n 'deny 62.60.130.228'"

# does the config parse?
ssh -t admin@example.com "sudo nginx -t"

For an end-to-end test, add your own address to the list on staging, confirm you get 403 Forbidden, and remove it again.

Which one to use

Situation fail2ban Nginx deny list
Brute-force wp-login.php attempts yes not needed
XML-RPC abuse yes not needed
Many different addresses catches each one too many to manage
Known persistent attacker rebans automatically permanent block
Attack on non-WordPress URLs cannot see it yes

Use fail2ban first. It needs no maintenance and short bans keep false positives cheap. Reach for the deny list only for the few addresses that keep coming back.

fail2ban needs control of the server’s firewall, so it is not an option on shared hosting.