While hardening a client's WordPress site, one item on my checklist was killing XML-RPC. That legacy endpoint is barely used anymore, nearly every modern integration goes through the REST API, yet it remains a favorite door for brute-force attacks. So I dropped in the classic snippet that floats around everywhere:
add_filter('xmlrpc_enabled', '__return_false');Checkbox ticked. XML-RPC dead. At least that is what I assumed, and it is exactly what the filter's name implies.
Some time later, a security scan report for that site landed on my desk. One finding made me stop scrolling: POST /xmlrpc.php with system.listMethods still came back HTTP 200, complete with a full method list. I did not take the scanner's word for it, so I checked from the terminal myself:
curl -s -X POST \
-d '<methodCall><methodName>system.listMethods</methodName></methodCall>' \
https://old-site.com/xmlrpc.phpAnd there it was. An XML response listing dozens of methods, from system.multicall to pingback.ping. The endpoint I believed was dead was alive, friendly, and happily telling anyone exactly what they could call.
Why this happens
The root cause is simple and mildly infuriating: the xmlrpc_enabled filter does not do what its name promises. It only disables the subset of methods that require authentication, the publishing paths like wp.getUsersBlogs and friends. The endpoint itself stays up. The system.* and pingback.* methods never pass through that gate at all, so they keep responding as if nothing happened. The WordPress documentation is actually honest about this, in a sentence almost nobody reads: the filter only covers XML-RPC methods requiring authentication.
The consequences are not cosmetic. system.multicall lets an attacker bundle hundreds of login attempts into a single HTTP request, the classic brute-force amplification trick. And pingback.ping can be abused to make your server take part in DDoS attacks against other sites. The two most notorious XML-RPC attack vectors, and both were still wide open after the supposedly "disabled" filter was in place.
The filter name oversells. xmlrpc_enabled sounds like the main breaker for the whole house, when really it is the light switch for one room.
The fix
Since one lock had already lied to me, I now layer. Layer one: empty the entire method table via xmlrpc_methods, the filter that actually holds the full list. Layer two: kill the endpoint as early as possible, before WordPress gets a chance to parse any XML payload at all.
// Layer 1: strip every XML-RPC method, including system.* and pingback.*.
add_filter('xmlrpc_methods', '__return_empty_array');
// Layer 2: short-circuit the endpoint before it does any real work.
add_action('plugins_loaded', function () {
if (defined('XMLRPC_REQUEST') && XMLRPC_REQUEST) {
status_header(403);
exit;
}
}, 0);Why both? xmlrpc_methods runs over the final method table, so returning an empty array means system.listMethods has nothing to report and nothing is callable. But if some plugin one day re-registers its own methods, the second layer still holds: WordPress defines the XMLRPC_REQUEST constant very early when xmlrpc.php handles a request, and a plugins_loaded hook at priority 0 exits before any other work happens.
Since I was already in the same hardening file, I also closed the info-disclosure findings from the same scan: the WordPress version leaking through the generator meta tag, plus the stock files that announce the version to anyone who drops by.
// Hide the WordPress version from the generator meta tag and feeds.
remove_action('wp_head', 'wp_generator');
add_filter('the_generator', '__return_empty_string');# .htaccess: 404 the stock info-disclosure files
RedirectMatch 404 ^/(readme\.html|license\.txt)$Verification
This is the part I skipped the first time and will never skip again. Re-run the exact request the scanner used, and look at the status code:
curl -s -o /dev/null -w '%{http_code}\n' -X POST \
-d '<methodCall><methodName>system.listMethods</methodName></methodCall>' \
https://old-site.com/xmlrpc.phpNow the answer is 403. No method list, no XML at all. That is evidence. Not an assumption, not a filter name that merely sounds convincing.
The takeaway
My mistake was not installing the snippet wrong; the snippet worked exactly as designed. My mistake was verifying the hardening by reading a filter's name instead of sending a real request. If you want XML-RPC actually dead, xmlrpc_enabled alone is not enough: empty xmlrpc_methods or block the endpoint outright, ideally both. Then finish with one curl against xmlrpc.php and watch the refusal happen with your own eyes. Since this incident, my rule is simple: hardening is not done until I have seen the real request get rejected.
