WordPress exposes two interfaces that let software talk to your site without a browser. Both are enabled by default, both are legitimately useful, and both are constantly probed by automated attacks.
The advice you will find is usually "disable them", and that is right for one and wrong for the other.
XML-RPC: the old one
xmlrpc.php predates the REST API. It exists so remote applications can post to your site.
Two things still use it: the Jetpack plugin, and some mobile publishing apps. If you use neither, nothing on your site needs it.
Why it is attacked
The problem is a feature called system.multicall, which lets one request contain many method calls.
An attacker can therefore try hundreds of passwords in a single HTTP request. Login-attempt limiting that counts requests does not notice, because there is only one request.
It is also used for amplification: your site can be induced to make requests to a third party, making it a participant in an attack on someone else.
Even when the attacks fail, they cost you. Each request starts PHP, and on a shared plan sustained probing consumes the processing your visitors need. Monitoring your hosting resources goes into spotting that.
Turning it off
The cleanest method is to block the file at the server, so requests never reach PHP at all. In .htaccess:
<Files xmlrpc.php> Require all denied </Files>
That is better than a plugin filter, because a plugin has to load WordPress to refuse the request, which is most of the cost you were trying to avoid.
Test afterwards: request the file and confirm a 403. Then check that whatever you do use still works, before you forget you made the change.
The REST API: do not disable it
This is where the common advice goes wrong.
The REST API is not a legacy interface. The block editor uses it: every save, every autosave, every media upload. Much of the modern admin interface depends on it, and a growing number of plugins have no other way of working.
Disable it wholesale and the editor stops saving. People then spend an afternoon on a WordPress fault they created deliberately.
What it actually exposes
The concern is real but narrower than it is usually described.
By default, /wp-json/wp/v2/users lists users who have published content, with their display names and their login slugs. That gives an attacker half of every credential on the site, and it takes one request with no authentication.
Everything else public through the API is content you already publish.
Restrict the user endpoint, not the API
The targeted fix, in the child theme's functions.php or a small site-specific plugin:
add_filter('rest_endpoints', function ($endpoints) {
if (!is_user_logged_in()) {
unset($endpoints['/wp/v2/users']);
unset($endpoints['/wp/v2/users/(?P<id>[\d]+)']);
}
return $endpoints;
});
Anonymous requests for the user list get nothing; the editor and plugins keep working because logged-in requests are untouched.
Put it in a child theme instead of the parent, so a theme update does not remove it. Creating a child theme sets out setting one up.
Author archives leak the same thing
Closing the endpoint alone is not enough, because /?author=1 redirects to the author archive whose URL contains the same login slug.
If you do not use author archives, disable them. If you do, set each user's nickname and display name to something other than their login, so the slug does not match the username.
That is the more robust fix in any case: a username that is never displayed anywhere cannot leak.
Application passwords
WordPress supports per-application credentials that authenticate to the REST API without the account password.
Use them for anything integrating with the site: they can be revoked individually, and they do not give access to the admin interface.
Review the list occasionally and remove ones you no longer use. A forgotten application password is a working credential nobody is watching.
What to actually do
Three things, in order.
Block xmlrpc.php unless something you use needs it.
Restrict the users endpoint to logged-in requests and make sure display names differ from usernames.
Leave the rest of the REST API alone.
None of it substitutes for the basics: strong passwords, two-factor on admin accounts, and current software. Those stop the attack that these measures only make more expensive. There is more in securing WordPress against vulnerabilities.
Check what you changed
After the block, request /xmlrpc.php and confirm 403.
After the endpoint filter, request /wp-json/wp/v2/users in a private window and confirm it returns nothing useful.
Then open the block editor and save a post. That is the test that catches an over-broad change, and it is the one people skip.
Find out what is actually being requested
Before changing anything, read how much of this traffic your site receives and what it is asking for.
awk '$7 ~ /xmlrpc\.php/ {print $1}' ~/logs/example.com | sort | uniq -c | sort -rn | head
awk '$7 ~ /wp-json/ {print $7}' ~/logs/example.com | sort | uniq -c | sort -rn | head -10
grep -c 'wp-json/wp/v2/users' ~/logs/example.com
The pattern tells you whether this is background noise or a directed attempt. Thousands of requests to the older endpoint from a handful of addresses is automated password guessing, and it consumes real resources whether or not it succeeds.
Requests to the user listing endpoint are reconnaissance. They are collecting the account names that will be tried afterwards, and seeing them in the log means the attempt is in progress rather than hypothetical.
Blocking the endpoint is not the same as blocking the requests
Turning off a feature stops it working. It does not stop the requests arriving, and the arrival is where the cost is.
A refused request still starts the application, loads the configuration and returns a response. At thousands of attempts an hour that is measurable load produced by traffic you have already decided to refuse.
<Files xmlrpc.php> Require all denied </Files>
Refusing at the web server means the application is never started for those requests. It is a different level of protection from a plugin setting, and on a site being actively probed the difference is visible in the load figure. Stopping bots and scrapers deals with the wider case.
Keep the credentials that machines use separate
Anything connecting to the site programmatically should not be using a person's password.
Application specific credentials can be issued per integration, revoked individually, and given an account with only the privileges that integration needs. A mobile app, a backup service and a publishing tool should each have their own.
wp user application-password list admin --fields=name,created,last_used 2>/dev/null wp user list --role=administrator --fields=user_login,user_email
Read the last used column. An entry that has never been used, or has not been used in months, is an open door for an integration nobody is running any more.
Revoking one of these stops that integration and nothing else, which is the entire point. Changing a shared password stops everything at once and is how these things end up never being rotated.