WordPress Login Hardening: Rate Limiting and Disabling XML-RPC
wp-login.php is the most brute-forced WordPress entry; xmlrpc.php is used for credential guessing and attack amplification. Nginx login-page rate limits, XML-RPC disable vs rate-limit, application-layer lockout, JetPack dependency checks, lockout recovery. Commands are copy-paste ready.
Applicable scenarios
- access.log shows a large volume of
POST /wp-login.phprequests, source IPs dispersed, continuous all day - Bandwidth saturated by
POST /xmlrpc.phppingback requests, or an abnormal surge on that path - WordPress site is not behind a WAF; block brute force and attack amplification at the Nginx layer
Prerequisites
- WordPress runs on Nginx (rules also apply to BaoTa and similar panels; write into the site config)
- Server root or panel config-file edit permission
- Confirm whether the site depends on JetPack, the WordPress mobile client, or other XML-RPC features
How it works
wp-login.php is WordPress's sole login entry. Attackers submit common username/password dictionaries at high frequency. Requests are legitimate POSTs; WordPress itself has no rate limit. xmlrpc.php's system.multicall packs hundreds of credential attempts into one request, amplifying brute-force efficiency 100x+. Pingback can be borrowed by third parties for amplification. Common fix: constrain at the web server — IP-based rate limit on the login page; return 403 or rate-limit xmlrpc.php.
Procedure
1. Define rate-limit zones
Add to the http block of the Nginx main config:
`limit_req_zone $binary_remote_addr zone=wplogin:10m rate=10r/m;
limit_req_zone $binary_remote_addr zone=xmlrpc:10m rate=1r/m;`
rate=10r/m means at most 10 login requests per IP per minute. Enough for humans; scripts stall.
2. Apply rate limits in the site config and disable XML-RPC
`server {
...
location = /wp-login.php {
limit_req zone=wplogin burst=5 nodelay;
limit_req_status 429;
include snippets/fastcgi-php.conf; # fill in with the site's original fastcgi config
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
}
location = /xmlrpc.php {
# If no feature depends on XML-RPC, return 403 directly:
return 403;
}
}`
The login location must keep the original fastcgi config, or the login page will serve PHP source as a download. If the site still uses XML-RPC, replace return 403 with a limit_req rate-limit.
3. Login-failure lockout (application-layer supplement)
Nginx rate limits by request frequency and cannot detect slow brute force. Add lockout logic in the theme's functions.php:
`add_filter('authenticate', function($user, $username, $password){
if (is_wp_error($user)) {
$key = 'fl_' . md5($_SERVER['REMOTE_ADDR']);
$fails = (int) get_transient($key);
if ($fails >= 5) {
return new WP_Error('locked', 'Too many attempts, please try again in 30 minutes');
}
set_transient($key, $fails + 1, 30 * MINUTE_IN_SECONDS);
}
return $user;
}, 30, 3);`
Locks the same IP for 30 minutes after 5 consecutive failures. Two-layer defense with Nginx rate limiting.
4. Reload Nginx
`nginx -t && nginx -s reload`
Verification
`# Rate-limit check: POST login requests in a short burst and watch the status codes
for i in $(seq 1 15); do
curl -s -o /dev/null -w "%{http_code}\n" -X POST https://helixcyber.ai/wp-login.php
done
# First few should be 200 or 302; 429 after the rate is exceeded`
XML-RPC check: curl -s -o /dev/null -w "%{http_code}" https://helixcyber.ai/xmlrpc.php, should return 403. Then log in once from a browser to confirm a real user is not rate-limited.
FAQ
FAQ 1: What breaks if XML-RPC is disabled?
JetPack and its site stats/auto-publish, WordPress mobile app application-password login, and some third-party sync tools depend on XML-RPC. If you use those, keep the rate-limit approach instead of return 403, and disable pingback in settings. Pure content sites can usually disable it outright.
FAQ 2: After the change, wp-login.php downloads as a PHP file?
The new location block omitted fastcgi config; Nginx served PHP as a static file. Copy the include and fastcgi_pass lines from the existing location ~ \.php$ block into the new location, or nest the limit_req directives inside the original PHP location.
FAQ 3: Locked out by the rate limit?
Switch networks or verify with local curl on the server. Then temporarily raise rate or comment out the limit_req lines and reload. Shared office egress IPs aggregate and trip the limit; if needed, map admin IPs to a separate key or allowlist them, then restore the rules.
Summary
WordPress login hardening tightens the entry at the web server: limit_req caps wp-login.php to single-digit requests per minute; xmlrpc.php gets 403 when unused, rate-limited when needed; application-layer lockout covers slow brute force. After config, batch-curl to confirm 429 and 403, and verify fastcgi config is intact. Low cost; stops most automated brute-force traffic.