← Back to blog

WordPress XML-RPC: the one-line disable does not disable it

The SandyWP team 7 min read

xmlrpc.php is the remote API WordPress shipped before the REST API existed. It still loads on every install, it still accepts a username and password in the request body, and it still answers pingbacks.

The advice everywhere is one line in a plugin file:

add_filter( 'xmlrpc_enabled', '__return_false' );

That line disables the authenticated methods only. Pingbacks keep working, your pages keep advertising the endpoint, and POST /xmlrpc.php keeps returning 200. Core's own docblock says this in as many words, and none of the guides that rank for this quote it.

All core code below was read from WordPress/wordpress-develop trunk on 2026-09-27.

What the filter says about itself

wp_xmlrpc_server::set_is_enabled() is where the filter runs, and the comment above it is unusually blunt:

Contrary to the way it's named, this filter does not control whether XML-RPC is *fully*
enabled, rather, it only controls whether XML-RPC methods requiring authentication -
such as for publishing purposes - are enabled.

Further, the filter does not control whether pingbacks or other custom endpoints that don't
require authentication are enabled.

The mechanism is simple once you see it. $this->is_enabled is only ever read inside login():

public function login( $username, $password ) {
	if ( ! $this->is_enabled ) {
		$this->error = new IXR_Error( 405, __( 'XML-RPC services are disabled on this site.' ) );
		return false;
	}
	...

Every method that needs a user calls login() first, so all of those stop. Nothing else consults the flag.

What survives the filter

Three things, and they are the reason "I disabled XML-RPC" and "xmlrpc.php is not reachable" are different statements.

pingback.ping still runs. It is registered in the constructor like every other method and it never calls login(), because a pingback is by definition an unauthenticated request from a stranger. This is the method used for reflected floods, where an attacker asks a thousand WordPress sites to fetch the same URL. The filter does nothing to it.

Your head tag still points at the file. rsd_link() prints the Really Simple Discovery link:

printf(
	'<link rel="EditURI" type="application/rsd+xml" title="RSD" href="%s" />' . "\n",
	esc_url( site_url( 'xmlrpc.php?rsd', 'rpc' ) )
);

And xmlrpc.php?rsd is answered before the server object is ever built. Look at the order in xmlrpc.php: the isset( $_GET['rsd'] ) branch prints the discovery document and calls exit above the require of class-wp-xmlrpc-server.php. So xmlrpc_enabled and xmlrpc_methods are never even loaded on that request. The discovery document still names four APIs and still gives your endpoint URL.

The X-Pingback header still goes out. WP::send_headers() adds it on single posts:

if ( is_singular() ) {
	$post = $wp_query->post ?? null;
	// Only set X-Pingback for single posts that allow pings.
	if ( $post && pings_open( $post ) ) {
		$headers['X-Pingback'] = get_bloginfo( 'pingback_url', 'display' );
	}
}

The condition is pings_open(), not the XML-RPC flag. Turn off pings in Settings and the header goes; turn off XML-RPC and it stays.

The brute-force claim is out of date

Almost every guide repeats a version of this: system.multicall lets an attacker put hundreds of wp.getUsersBlogs calls in one request and try hundreds of passwords in one round trip.

That amplification is gone. login() sets a flag on the first failure and refuses every later attempt in the same request:

if ( $this->auth_failed ) {
	$user = new WP_Error( 'login_prevented' );
} else {
	$user = wp_authenticate( $username, $password );
}
$this->error = new IXR_Error( 403, __( 'Incorrect username or password.' ) );

// Flag that authentication has failed once on this wp_xmlrpc_server instance.
$this->auth_failed = true;

One server instance per request, so one wrong password per request. system.multicall still exists in IXR_Server and a multicall of 500 logins still parses, but 499 of them come back login_prevented without touching wp_authenticate().

XML-RPC is still a login endpoint that most people do not rate-limit and do not watch, and one password per request is still one password per request. It is a worse story than the REST API, which uses application passwords rather than the account password. It is not the amplifier the articles describe.

So what does turning it off properly look like

Pick by what you want to stop.

Stop authenticated publishing, keep pingbacks. The one-line filter. Rarely what anyone actually wants, but it is what the one line does.

Stop everything the server answers. Empty the method table too:

add_filter( 'xmlrpc_enabled', '__return_false' );
add_filter( 'xmlrpc_methods', '__return_empty_array' );

Now pingback.ping is not registered, so it comes back as an unknown method. The file still loads WordPress on every hit, which still costs you a full bootstrap per request.

Stop the file being reached at all. Deny it at the web server, before PHP starts. On nginx:

location = /xmlrpc.php {
	deny all;
}

This is the only option that costs nothing per request, and the only one that is invisible to a scanner probing for the endpoint. It is also the one that breaks things hardest if something was using it, because the client gets a 403 from the server rather than a fault from WordPress.

Also turn off pings, in Settings, Discussion, if the pingback surface is what you care about. That is what drops the X-Pingback header, and it is independent of all of the above.

The thing to test before you do it on a live site

Two failure modes, and they look nothing alike.

The first is obvious. Something you own uses XML-RPC, it breaks, you notice. Jetpack historically needed it, and so did anything speaking MetaWeblog or Movable Type, which is most desktop blogging clients and a long tail of publishing integrations.

The second is not obvious, and it is the one that costs you a weekend. Blocking xmlrpc.php at the web server means a client that used to get an XML fault now gets an HTML error page. Anything that parsed the fault to decide what to do next will not handle that, and it usually fails quietly rather than loudly.

So check by request, not by loading the homepage. A well-formed probe that does not need credentials:

curl -s -o /dev/null -w '%{http_code}\n' https://example.com/xmlrpc.php

curl -s https://example.com/xmlrpc.php \
  -H 'Content-Type: text/xml' \
  --data '<?xml version="1.0"?><methodCall><methodName>system.listMethods</methodName><params></params></methodCall>'

The first tells you whether the file is reachable. A bare GET on a working install returns 405 with the message that XML-RPC only accepts POST, so 405 means reachable and 403 means blocked upstream. The second shows you which methods are registered, so you can see the difference between the filter alone and the filter plus an empty method table.

Then check the two things the filter does not touch:

curl -s https://example.com/xmlrpc.php?rsd | head -5
curl -sI https://example.com/a-published-post/ | grep -i pingback

If either still answers, XML-RPC is advertised on your site whatever you put in your plugin file.

Rehearse it on a copy, not on the site

All of this is a small amount of config with an awkward blast radius, which makes it exactly the kind of change worth making twice.

Take a copy of the real site with its own database, apply the change there, and run the four curl commands above against the copy. If Jetpack or a publishing integration is wired up, the copy is also where you find that out, because it will fail there instead of in production.

You need shell and file access for that rather than just wp-admin, since the nginx rule and the plugin file are both files. SSH and SFTP into a throwaway site and the CLI are enough, and with debug mode on you can see whether the block is producing PHP errors somewhere you did not expect.

For a client site where you cannot enumerate every integration, this is the whole argument for doing it on a disposable copy first. Agencies inherit sites with a decade of plugins on them, and "nothing was using XML-RPC" is a guess until a request proves it.

The short version

  • xmlrpc_enabled stops authenticated methods. It does not stop pingbacks, the RSD link, or the X-Pingback header, and core's docblock says so.
  • Add xmlrpc_methods returning an empty array to stop pingback.ping, or deny the file at the web server to stop the bootstrap as well.
  • The system.multicall password-amplification attack does not work on current core: one failed login poisons the rest of the request.
  • Turn off pings separately if you want the header gone.
  • Probe with system.listMethods and check ?rsd afterwards, because "I added the filter" is not the same as "the endpoint is closed".