wp-login.php is, by a wide margin, the most attacked URL on the entire web, simply because WordPress runs a large share of the web and every install has that file sitting at the same predictable path. Automated bots do not need to find your login page; they already know exactly where it is, and they hit it with thousands of common username and password combinations continuously, whether or not anyone is watching. A default WordPress install has no rate limiting on login attempts at all, which means a self-managed VPS with nothing extra configured is left to absorb that traffic indefinitely.
This guide adds two layers of protection: Nginx-level rate limiting that slows down repeated requests to wp-login.php before they even reach PHP, and a dedicated Fail2Ban jail that bans IPs making a high volume of login requests outright. It assumes you have WordPress running on Nginx already, for example from the [WordPress deployment guide](/guides/deploy-wordpress-nginx-php-fpm), and that [UFW and Fail2Ban](/guides/securing-vps-ufw-fail2ban) are already installed and running for SSH protection.
Nginx can throttle requests to a specific path before they ever reach PHP-FPM, which matters because every login attempt that does reach PHP still costs CPU and a database query, even a failed one. Open the main Nginx configuration file:
sudo nano /etc/nginx/nginx.conf
Inside the http block, add a rate limit zone that tracks requests per client IP:
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
This defines a zone named login, allocates 10MB of memory to track client IPs, which comfortably covers tens of thousands of unique addresses, and limits each IP to 5 requests per minute against any location that references this zone. Five login attempts a minute is well above what a real person needs, even accounting for a mistyped password, and well below what a brute-force script wants to run.
With the zone defined, apply it specifically to the login file rather than the whole site, so normal browsing traffic is never affected. Open your site's server block:
sudo nano /etc/nginx/sites-available/example.com
Add a location block matching wp-login.php. Its position in the file relative to other location blocks does not matter here, since Nginx always evaluates an exact-match location, one written with a bare = before the path, ahead of any prefix or regex location regardless of where it appears in the config:
location = /wp-login.php {
limit_req zone=login burst=3 nodelay;
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
burst=3 allows a short burst of up to 3 requests above the steady rate before limiting kicks in, which absorbs the occasional double-click or page refresh without treating it as an attack. nodelay applies the limit immediately rather than queueing excess requests, returning a 503 to anything over the limit right away instead of making the client wait. Test the configuration and reload:
sudo nginx -t
sudo systemctl reload nginx
Test the limit before relying on it, since a typo in the block, for example a missing = or a stray trailing slash, is a common mistake and one that silently does nothing if the location block does not match the request Nginx actually receives. Send several rapid requests to wp-login.php:
for i in {1..10}; do curl -s -o /dev/null -w "%{http_code}\n" https://example.com/wp-login.php; done
Output:
200
200
200
200
503
503
503
503
503
503
The first few requests, covered by the burst allowance, return 200. Once the burst is exhausted, subsequent requests return 503 until the rate window resets. If every request still returns 200, confirm the block above is inside the correct server block for the domain being tested, and that the path matches exactly; a location = block only matches the precise path /wp-login.php, not variations with query strings or trailing characters.
Rate limiting slows down an attack; a Fail2Ban jail bans the IP outright after a threshold of requests, which is a stronger response for sustained attacks. WordPress returns the same HTTP 200 status for both a successful and a failed login attempt, the actual outcome lives in the response body rather than the status code, so a filter built on the default Nginx access log cannot distinguish a failed login from a successful one. This jail works on request volume instead: an IP hitting wp-login.php or xmlrpc.php repeatedly in a short window is almost certainly a bot, not a person, regardless of whether any individual attempt succeeded.
Create a Fail2Ban filter that matches POST requests to either file in the access log:
sudo nano /etc/fail2ban/filter.d/wordpress-login.conf
[Definition]
failregex = ^<HOST> .* "POST /wp-login.php
^<HOST> .* "POST /xmlrpc.php
ignoreregex =
xmlrpc.php is included alongside wp-login.php because it is another common brute-force target; WordPress's XML-RPC API can be used to attempt logins via its system.multicall method, bypassing the login form entirely while hitting the same underlying authentication check. Now create the jail that uses this filter:
sudo nano /etc/fail2ban/jail.local
Add a new jail section, or append this to the file if jail.local already exists from the UFW and Fail2Ban setup:
[wordpress-login]
enabled = true
port = http,https
filter = wordpress-login
logpath = /var/log/nginx/access.log
maxretry = 10
findtime = 300
bantime = 3600
maxretry = 10 with findtime = 300 bans an IP that hits wp-login.php or xmlrpc.php 10 times within a 5-minute window, and bantime = 3600 keeps it banned for an hour. This threshold is set deliberately loose, since it counts every request regardless of outcome; a real person retrying a forgotten password a few times, or a browser retrying a slow request, should stay well under 10 hits in 5 minutes, while a brute-force script attempting dozens of combinations a minute will cross it quickly. If your own login habits or a legitimate integration hitting xmlrpc.php trip this threshold, raise maxretry rather than lowering it, since there is no way for this filter to tell a real repeated attempt from an automated one beyond volume. Restart Fail2Ban to load the new jail:
sudo systemctl restart fail2ban
sudo fail2ban-client status wordpress-login
The status command confirms the jail loaded correctly and shows current ban counts, which starts at zero and climbs as real attack traffic gets caught.
wp-login.php now rate-limits at the Nginx level and bans high-volume requesters outright through Fail2Ban, both without touching the login form itself or requiring a security plugin running inside WordPress. From here, a strong, unique admin password and two-factor authentication on the WordPress account itself close the remaining gap: these server-level defenses reduce the volume of attempts that get through, but the account credentials are still the last line of defense.
Move a live WordPress site from shared hosting to a VPS: database export with my...
How to set up SSH key authentication on Ubuntu and disable password logins to se...
Install WordPress on a Linux VPS with Nginx, PHP-FPM, and MariaDB. Complete step...