Ahosting Logo
Knowledge Base

Securing the WordPress REST API and XML-RPC

Two interfaces that need opposite treatmentXML-RPCREST APIUsed byJetpack, some mobile publishing appsthe block editor, and much of the adminBlocking itfine, unless you use thosebreaks the editorThe exposurepassword authentication, bypassing twofactorthe users endpoint listing usernamesThe right actionblock it at the serverrestrict that one endpointBlocking the REST API wholesale is a common recommendation and it breaks the editor, which is why the answer is oneendpoint rather than the interface.

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.