Guide
WordPress REST API hammered with login attempts — how to detect credential stuffing
1. Problem
Your WordPress site is slow. The dashboard takes seven seconds to load. PHP-FPM workers are pinned. The Fail2Ban rule you set up two years ago for wp-login.php shows nothing unusual. But something is wrong.
You tail the access log and see this, repeating thousands of times per minute:
185.220.101.42 - - [27/Apr/2026:14:21:09 +0000] "POST /wp-json/jwt-auth/v1/token HTTP/1.1" 401 89
185.220.101.42 - - [27/Apr/2026:14:21:09 +0000] "POST /wp-json/jwt-auth/v1/token HTTP/1.1" 401 89
185.220.101.42 - - [27/Apr/2026:14:21:10 +0000] "POST /xmlrpc.php HTTP/1.1" 200 412
This is the classic WordPress REST API brute force pattern, and it is the modern face of credential stuffing against WordPress. The attacker is not hitting wp-login.php because that endpoint is monitored, rate-limited, and behind every "WordPress security plugin" on the planet. They are hitting the REST API and XML-RPC instead — endpoints that authenticate the same users, against the same database, but are almost never logged or alerted on.
If you searched for "wordpress rest api brute force" or "wordpress xmlrpc credential stuffing" or "wordpress login attack rest endpoint" and ended up here, you are probably watching this happen in real time. Below is how to confirm it, stop it, and make sure you see the next one before it eats your CPU.
2. Impact
Credential stuffing against WordPress is not theoretical. Botnets cycle through leaked credential dumps from other breaches and replay them against any WordPress site that accepts authentication. With one weak admin password and one successful match, the attacker gets:
- Full administrative control of the site
- Plugin and theme installation rights — meaning persistent backdoors
- Database export capability — including hashed passwords and PII
- The ability to modify content, redirect traffic, or insert SEO spam
- A stepping stone into shared hosting infrastructure or staging environments
The cost while the attack is ongoing — even before a successful breach — is also concrete. PHP-FPM saturation means real users get 502s. Database connections fill up. Caching layers thrash. CDN bandwidth costs spike. If you sell anything on the site, conversion drops the moment response time crosses two seconds.
And the worst case is not the attack you see. It is the slow, low-volume one you do not.
3. Why It’s Hard to Spot
WordPress core does not log failed REST API or XML-RPC authentication anywhere visible. There is no dashboard widget. There is no email. The wp_login_failed action fires for wp-login.php form submissions, but REST and XML-RPC authentication take a different code path through wp_authenticate_application_password and wp_xmlrpc_server::login, and those failures are silent by default.
Most security plugins are also blind here. They were written when wp-login.php was the only door. They count failed POSTs to that URL and ban IPs that exceed a threshold. They do not parse /wp-json/* routes. They do not decompose system.multicall payloads. They look at the front door while the side door is open.
Uptime checks miss it entirely. The site is up. It is just slow and getting probed. Synthetic checks return 200. CPU graphs show "elevated load" with no clear cause. The hosting support ticket comes back with "looks normal on our end."
By the time someone notices — usually because the site went fully down or a real user complained — the attacker has either already succeeded or moved on with a list of valid usernames they can come back to later.
4. Cause
The rest.request signal represents a single authenticated or attempted call to the WordPress REST API surface — anything under /wp-json/. Every time WordPress dispatches a REST route, a rest.request is emitted with payload fields including the namespace, route, method, status code, source IP, user agent, and authentication outcome.
In a healthy WordPress site, rest.request traffic is dominated by:
- Block editor saves (
/wp-json/wp/v2/posts) - Plugin admin AJAX (e.g. WooCommerce orders, Yoast indexable updates)
- Authenticated user actions for logged-in subscribers or members
- Public read endpoints from the front-end (search, comments, etc.)
In a credential stuffing attack, the shape changes drastically. You see:
- Sustained high volume of
POSTrequests to a small set of authentication routes —/wp-json/jwt-auth/v1/token,/wp-json/wp/v2/users/me,/wp-json/simple-jwt-login/v1/auth, or any plugin-provided login endpoint - A high
4xxrate (401, 403) — meaning most attempts fail - Each
rest.requestfailure is paired with anauth.attemptsignal carryingresult=failure - A flat or rotating distribution of source IPs, often from datacenter or residential proxy ranges
- User agents that are either absent, generic (
python-requests/2.31.0), or copied from stale Chrome versions
The XML-RPC variant is the same attack with a different door. xmlrpc.php accepts a wp.getUsersBlogs call that authenticates a user — and importantly, it accepts the system.multicall method, which lets an attacker batch hundreds of login attempts into a single HTTP request. One http.request to /xmlrpc.php can hide 500 auth.attempt failures behind it.
This is why simple HTTP-level rate limiting fails. The attacker is making one slow request that internally does five hundred password checks.
5. Solution
5.1 Diagnose (logs first)
This is the diagnostic playbook. Every step ties back to either rest.request, auth.attempt, or http.request.
Step 1: Confirm the attack on the access log
Start at the web server. Nginx and Apache both log every hit, including REST API and XML-RPC.
# Nginx — show top IPs hitting REST API auth endpoints in the last 10k lines
tail -n 10000 /var/log/nginx/access.log \
| grep -E "POST /wp-json/(jwt-auth|simple-jwt-login|wp/v2/users)" \
| awk '{print $1}' | sort | uniq -c | sort -rn | head -20
# Apache equivalent
tail -n 10000 /var/log/apache2/access.log \
| grep "POST /wp-json/" | grep -E " 40[13] " \
| awk '{print $1}' | sort | uniq -c | sort -rn | head -20
Each line that matches here is a http.request event. Each one targeting an auth route would, with proper instrumentation, also produce a rest.request event with auth=failure. If you see a single IP responsible for thousands of hits in a few minutes, that is the attack.
Step 2: Confirm the XML-RPC vector
grep "POST /xmlrpc.php" /var/log/nginx/access.log \
| awk '{print $1, $9}' | sort | uniq -c | sort -rn | head
Look for unusual volume — even 200-status XML-RPC POSTs are suspicious if you are not running Jetpack or a remote-publishing client. Each of these is a http.request, and each one may contain dozens of underlying auth.attempt events from system.multicall.
Step 3: Confirm authentication failures inside WordPress
If you have enabled WordPress debug logging or any audit plugin, check wp-content/debug.log and the PHP error log:
grep -E "wp_authenticate|incorrect password|xmlrpc.*authenticat" \
/var/log/php/error.log /var/www/wp-content/debug.log 2>/dev/null
Each failed authentication, regardless of whether it came from wp-login.php, REST, or XML-RPC, is an auth.attempt signal with result=failure. The volume here, cross-referenced against the IPs from Step 1, confirms the brute-force pattern.
Step 4: Look for username enumeration as a precursor
Credential stuffing is often preceded by enumeration. The REST API exposes /wp-json/wp/v2/users publicly on many sites:
grep "GET /wp-json/wp/v2/users" /var/log/nginx/access.log \
| awk '{print $1}' | sort | uniq -c | sort -rn | head
Bursts of GETs to this endpoint immediately before POST floods to login routes are rest.request events that should have raised an enumeration signal on their own.
Step 5: Use WP-CLI to confirm impact
wp eval 'echo count(get_users(["meta_key" => "session_tokens"]));'
If active sessions are climbing while you are under attack, the attacker may already have a valid login.
5.2 Root Causes
(see root causes inline in 5.3 Fix)
5.3 Fix
There are three primary root causes for the attack succeeding or even being possible. Address them in order.
Cause A: The REST API authentication routes are publicly reachable without rate limiting
Signal evidence: sustained rest.request volume against /wp-json//auth, /wp-json/jwt-auth/*, or /wp-json/wp/v2/users/me, paired with 401-status http.request entries from the same IPs.
Fix:
- At the edge (Cloudflare, nginx, ALB), add a rule limiting POST to
/wp-json/to 10 requests per minute per IP. - For nginx specifically:
limit_req_zone $binary_remote_addr zone=wpapi:10m rate=10r/m;
location ~ ^/wp-json/.*/(token|auth|login|users/me) {
limit_req zone=wpapi burst=5 nodelay;
try_files $uri $uri/ /index.php?$args;
}
- If you do not use the REST API for your own application, restrict authenticated routes to logged-in users via a
rest_authentication_errorsfilter.
Cause B: XML-RPC is enabled and accepting system.multicall
Signal evidence: http.request events targeting /xmlrpc.php with 200 status and unusual request body sizes; auth.attempt failures arriving in bursts of 50+ per second from a single source.
Fix:
- Disable XML-RPC entirely if you do not use it. Add to
functions.php:
add_filter('xmlrpc_enabled', '__return_false');
- Or block at the web server:
location = /xmlrpc.php { deny all; return 403; }
- If you need XML-RPC for Jetpack, restrict
/xmlrpc.phpto Jetpack's published IP ranges.
Cause C: Weak admin passwords + no MFA
Signal evidence: any successful auth.attempt with result=success originating from an IP that produced multiple result=failure events in the preceding minutes is a probable compromise.
Fix:
- Force a password reset for every administrator and editor account.
- Enforce MFA on all privileged roles via a vetted plugin (Two Factor, miniOrange, or your SSO provider).
- Audit existing user accounts:
wp user list --role=administrator. Remove any that are unused or unrecognized. - Check for plugin or theme files modified in the last 30 days — credential stuffing that succeeds usually leads to a webshell drop.
5.4 Verify
The signal that should disappear is the high-volume failure pattern on rest.request and the matching auth.attempt failure cluster.
After applying the fixes, watch for 30–60 minutes under normal traffic and check:
# Should drop to single-digit POSTs per minute or zero
tail -f /var/log/nginx/access.log \
| grep --line-buffered -E "POST /wp-json/.*(token|auth|users/me)|POST /xmlrpc.php"
# Should show no new authentication failures from external IPs
tail -f /var/log/php/error.log | grep -i "authenticat"
Healthy looks like:
rest.requestPOST volume to authentication routes drops to occasional, expected traffic from your own admins or known integrationsauth.attemptfailure rate drops below one per minute site-wide- No 401 responses from
/wp-json/jwt-auth/*or/wp-json/wp/v2/users/meappear at all from unfamiliar IP ranges - PHP-FPM worker count returns to baseline; database connection count falls
If after one hour you still see rest.request failures from new IPs, the attacker has rotated source IPs and you are dealing with a distributed botnet. Move the rate limit to a global counter at the application layer or block at the WAF on user agent and behavioral fingerprint, not just IP.
6. How to Catch This Early
Fixing it is straightforward once you know the cause. The hard part is knowing it happened at all.
This issue surfaces as rest.request.
The hard part of this attack is not stopping it. Once you see it, the fixes above are mechanical. The hard part is knowing it is happening.
WordPress will not tell you. Your hosting provider will not tell you. Your uptime monitor will not tell you. The plugin you installed in 2022 watches wp-login.php and ignores /wp-json/. Most credential stuffing campaigns are caught only after a successful login leads to visible damage — defacement, malicious plugin upload, redirect injection — by which point the recovery cost is days of work.
This is exactly the class of failure Logystera is built for. The rest.request signal, emitted by the Logystera WordPress plugin on every REST API hit, exposes auth-route volume and failure rates as a first-class metric. The auth.attempt signal aggregates failures across every authentication path — REST, XML-RPC, and wp-login.php — into one number you can alert on. When that number crosses a threshold, or when the per-IP failure rate spikes, Logystera detects it within seconds and surfaces the offending IPs, target routes, and timing in a single view.
The detection rule is not magic. It is just looking at the right signal. The reason it works is that the signal exists at all and is being watched continuously, instead of buried in a log file no one tails.
7. Related Silent Failures
If you found this guide useful, the following failures live in the same signal cluster — same logs, same blind spots, often the same attackers:
- WordPress username enumeration via
/wp-json/wp/v2/users— burst ofrest.requestGETs to the users endpoint, often the reconnaissance phase before credential stuffing. - WordPress XML-RPC
system.multicallamplification — singlehttp.requesthiding hundreds ofauth.attemptfailures; the variant most likely to bypass naive rate limits. - Application Password abuse after credential stuffing succeeds —
rest.requesttraffic continuing from a single IP after successful login, using a long-lived application password instead of a session cookie. - WordPress login brute force on
wp-login.php— the classic version of this attack; sameauth.attemptsignal, different surface. Worth watching together with REST and XML-RPC. - Plugin auto-update breakage masking compromise —
wp.state_changeevents appearing right after a successful credential-stuffed login; often the attacker installing a backdoor disguised as an update.
Each of these surfaces as a distinct signal pattern, and each is invisible without log-driven detection. They are the same problem wearing different clothes.
See what's actually happening in your WordPress system
Connect your site. Logystera starts monitoring within minutes.