Password reset is one of the most overlooked attack vectors in WordPress security. While login brute force is well known and often protected, the wp-login.php?action=lostpassword page is almost always ignored, and that's exactly what many automated bots target.
What follows is an analysis of a real attack, showing how to recognize it in the logs and how to block it with nginx configuration (on Ubuntu Linux).
What the attack looks like
The first clue is usually an email notification from WordPress: someone has requested a password reset for an administrator account. Looking at the nginx logs, it quickly becomes clear this isn't human error.
grep -i "lostpassword" /var/log/nginx/site_admin_access.log | tail -30
A mechanical sequence of requests compressed into a few seconds emerges:
[14:36:07] GET /wp-login.php?action=lostpassword 200
[14:36:10] POST /wp-login.php?action=lostpassword 302
[14:36:10] GET /wp-login.php?checkemail=confirm 200
[14:36:12] GET /wp-login.php?action=rp&key=xkQvn...&login=username 302
[14:36:13] GET /wp-login.php?action=lostpassword&error=invalidkey 200
[14:36:14] GET /wp-login.php?action=lostpassword 200
[14:36:15] POST /wp-login.php?action=lostpassword 200
[14:36:16] GET /wp-login.php?action=lostpassword 200
[14:36:17] POST /wp-login.php?action=lostpassword 200
Three telltale patterns:
Continuous cycle
GET and POST alternate at a one-second cadence for whole minutes at a time. No human would do that.
A different user-agent on every request
Chrome on Windows, then Firefox on Linux, then Safari on macOS. Bots rotate user agents to evade UA-based filters.
Immediate attempt at the reset key
The bot sends the POST, gets the 302 email-confirmation response, and within 2 seconds calls the reset URL with a key. The key turns out invalid (error=invalidkey), but the cycle restarts without stopping.
Counting the requests gives a sense of the scale of the problem:
grep "lostpassword" /var/log/nginx/site_admin_access.log | wc -l
grep "lostpassword" /var/log/nginx/site_admin_access.log.1 | wc -l
In the attack analyzed here: 88 requests on the current day, 45 the day before, at the same hour. A scheduled cron job.
How the bot enumerates usernames
WordPress, which isn't exactly an impenetrable fortress by default, exposes usernames in at least three places that are often overlooked. Simply renaming the admin username isn't enough if these endpoints remain accessible.
REST API
The /wp-json/wp/v2/users endpoint returns the user list without authentication by default.
curl -s https://your-site.com/wp-json/wp/v2/users | python3 -m json.tool | head -20
Typical response:
[
{
"id": 1,
"name": "John Smith",
"slug": "john_admin",
"link": "https://example.com/author/john_admin/"
}
]
The slug field matches exactly the login name WordPress uses for authentication.
Author page redirects
WordPress responds to /?author=1 with a 301 redirect to /author/john_admin/, revealing the login name of user ID 1. By iterating over ?author=1, ?author=2 and so on, a bot can enumerate all registered users.
curl -sI "https://your-site.com/?author=1" | grep Location
XML-RPC
The xmlrpc.php file, enabled by default, supports the wp.getUsersBlogs method and allows an unlimited number of authentication attempts within a single HTTP request, making it more dangerous than the classic login page.
curl -s -o /dev/null -w "%{http_code}" https://your-site.com/xmlrpc.php
200 means accessible, 403 means it's already blocked.
Nginx countermeasures
All the configurations below act before the request ever reaches PHP. This is the most efficient point to intervene: a WordPress plugin runs inside PHP, so the request still gets there regardless. Rate limiting at the nginx level blocks the bot before any memory is even allocated for PHP.
Rate limiting on wp-login.php
In a file within nginx's http context, for example /etc/nginx/conf.d/wp-security.conf:
limit_req_zone $binary_remote_addr zone=wp_login:10m rate=5r/m;
This directive defines a zone called wp_login that tracks IPs in 10MB of shared memory, with a limit of 5 requests per minute per IP address.
In the site's vhost, a dedicated location block for wp-login.php:
location = /wp-login.php {
limit_req zone=wp_login burst=3 nodelay;
limit_req_status 429;
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
burst=3 allows an initial burst of 3 closely-spaced requests before the limit kicks in. nodelay makes nginx respond immediately with 429 instead of queuing requests. With this configuration, the bot's cycle of dozens of POSTs per minute gets cut off after the first few requests.
Blocking XML-RPC
location = /xmlrpc.php {
deny all;
return 403;
}
If XML-RPC isn't actively used (Jetpack, the WordPress mobile app), blocking it removes an attack vector with no functional impact.
Blocking user enumeration via the REST API
location ~ ^/wp-json/wp/v2/users {
deny all;
return 403;
}
This rule only blocks the user list endpoint, without interfering with the rest of the REST API.
To also block the ?author=N redirect, you need to intervene on the WordPress side, since nginx doesn't easily filter on GET parameters. A plugin like Stop User Enumeration, or custom code in functions.php, handles this case.
Applying the changes
sudo nginx -t
sudo nginx -s reload
Quick check
Four commands to understand a WordPress site's situation in under a minute.
Requests to wp-login.php in the last 24 hours:
grep "wp-login" /var/log/nginx/your_site_access.log | wc -l
Check whether the REST API exposes the user list:
curl -s https://your-site.com/wp-json/wp/v2/users | python3 -m json.tool | head -20
Check whether the author redirect works:
curl -sI "https://your-site.com/?author=1" | grep -i location
Check whether xmlrpc.php responds:
curl -s -o /dev/null -w "%{http_code}" https://your-site.com/xmlrpc.php
Warning signs in the logs
| Sign | Meaning |
|---|---|
Dozens of POSTs to wp-login.php in a few minutes |
Active bot |
| Different user-agent on every request | UA filter evasion |
| Same pattern every day at the same time | Scheduled job |
action=rp&key=... right after checkemail=confirm |
Bot with access to the mailbox or reuse of earlier keys |
FAQ
Is password reset dangerous even if the mailbox is secure?
If the admin's email hasn't been compromised, the reset alone isn't enough to gain access. The attack is still a problem though: it floods the mailbox with useless notifications, generates load on PHP, and confirms to the bot that the username is valid (WordPress responds differently to existing vs. nonexistent usernames).
Is a security plugin like Wordfence enough?
Wordfence operates at the PHP level, so the request still reaches the process. For high volumes, nginx rate limiting is more efficient because the block happens before PHP is even started.
Doesn't rate limiting also block legitimate users?
With rate=5r/m burst=3, a user who forgets their password and retries 2-3 times has no issues. The block only triggers on automated volumes.
Can Fail2ban help?
Yes, but it requires the logs to capture the client's real IP. If the site sits behind a reverse proxy or CDN, make sure nginx is configured with the appropriate set_real_ip_from and real_ip_header, otherwise fail2ban would always see the proxy's IP and couldn't ban the real attacker.
References: