An external scan of a WordPress site shows what an attacker sees before trying anything: the core version, the theme and plugins with their versions, whether xmlrpc.php answers, whether the REST API lists usernames, whether backups and debug logs are reachable, and which security headers are missing. Each of those is a fix you can make in minutes, and together they remove the reconnaissance most attacks depend on.

Why WordPress is scanned differently

WordPress is not less secure than other platforms; it is more uniform. The same file paths, the same login page, the same API routes and the same plugin ecosystem exist on tens of millions of sites, which makes it economical to automate attacks against all of them at once. An attacker does not need to study your site. They need to confirm it is WordPress, read the version numbers it announces, and check a vulnerability database for a match. Everything in this guide is about breaking that pipeline: reveal less, close the endpoints that make automated attacks cheap, and keep the code current so that the version numbers you cannot hide do not match anything exploitable.

The general scanning model applies, but the WordPress-specific checks matter more than the generic ones because they map directly onto what mass-exploitation tooling looks for.

Fingerprinting: what your site announces

WordPress advertises itself in several places, and a scanner reports each under WordPress version disclosure.

  • The <meta name="generator" content="WordPress 6.x"> tag in the page head.
  • Version query strings on core assets: /wp-includes/js/jquery/jquery.min.js?ver=3.7.1 and ?ver=6.x on stylesheets.
  • /readme.html at the site root, which states the version in its heading.
  • The RSS feed's <generator> element and the REST API's index at /wp-json/.
  • Plugin and theme paths under /wp-content/plugins/name/ and /wp-content/themes/name/, whose readme.txt and style.css files state their versions.

Hiding the version is a weak defence on its own, since plugin paths and asset fingerprints reveal it anyway and an attacker with an exploit will simply try it. The point of reducing disclosure is to stay out of the cheap, automated tier of attacks that filter by advertised version, and to avoid handing the exact target to whoever is looking. Remove the generator tag and strip version strings with a few lines in the theme's functions.php or a small plugin, and delete readme.html and license.txt on deploy. Then spend the real effort on updates, because a current version has nothing to hide.

// functions.php: remove generator tag and ?ver= strings
remove_action( 'wp_head', 'wp_generator' );
add_filter( 'the_generator', '__return_empty_string' );
function scan_strip_ver( $src ) { return remove_query_arg( 'ver', $src ); }
add_filter( 'style_loader_src',  'scan_strip_ver', 9999 );
add_filter( 'script_loader_src', 'scan_strip_ver', 9999 );

xmlrpc.php: the endpoint you almost certainly do not need

xmlrpc.php is WordPress's legacy remote-procedure interface, used historically by the mobile apps, desktop publishing clients and the Jetpack plugin. It is enabled by default and it has two properties attackers value. First, the system.multicall method lets a single HTTP request attempt hundreds of username and password combinations, which turns a login brute-force from thousands of requests into a handful and sidesteps rate limits applied to wp-login.php. Second, the pingback.ping method makes your server fetch an arbitrary URL, which has been abused to turn WordPress sites into a distributed denial-of-service amplifier and to probe internal networks. A scanner confirms the endpoint by requesting it and reading the characteristic "XML-RPC server accepts POST requests only" response; the finding is reported under WordPress xmlrpc.php enabled.

Modern clients use the REST API instead. Unless you know that a specific integration needs XML-RPC (Jetpack is the common one, and it can work without it), turn it off at the application layer and block it at the web server so the PHP process is never reached:

// functions.php or a mu-plugin
add_filter( 'xmlrpc_enabled', '__return_false' );
add_filter( 'xmlrpc_methods', function ( $methods ) {
    unset( $methods['pingback.ping'], $methods['system.multicall'] );
    return $methods;
} );
# nginx
location = /xmlrpc.php { deny all; return 403; }

# Apache .htaccess
<Files "xmlrpc.php">
    Require all denied
</Files>

User enumeration: handing over half of every login

A password guess needs a username. WordPress provides them. The REST API endpoint /wp-json/wp/v2/users returns every user who has published a post, with their login slug, to any unauthenticated request; /?author=1 redirects to the author archive at /author/username/; and the login form's error messages have historically differed between "unknown user" and "wrong password". A scanner requests the REST endpoint and reports a listing under WordPress user enumeration via REST API. Combined with an open xmlrpc.php, this is a complete brute-force kit assembled from your own site.

// restrict the users endpoint to authenticated requests
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;
} );

// stop ?author=N redirects revealing the login name
add_action( 'template_redirect', function () {
    if ( is_author() && ! is_user_logged_in() ) {
        wp_safe_redirect( home_url(), 301 );
        exit;
    }
} );

Also make the display name differ from the login name for every account, especially administrators, so the author byline on posts does not double as the username. Application passwords, introduced in WordPress 5.6, mean that an integration that genuinely needs REST access can authenticate without the endpoint being public.

Plugins and themes: where the vulnerabilities actually are

Core WordPress is maintained by a large team, patched quickly and updated automatically for security releases. The plugin ecosystem is not. Most published WordPress vulnerabilities are in plugins and themes, many of them abandoned, and a site's real exposure is roughly proportional to how many it runs and how long since each was updated. An external scanner can see plugin directories referenced in the page source and read their versions from public files; the JavaScript library scanner adds the bundled front-end libraries, since a plugin that ships jQuery 1.12 ships its known issues too, reported under JavaScript library with known vulnerabilities.

PracticeWhy
Delete inactive plugins and themes, do not just deactivateInactive code is still on disk and still reachable by direct URL; several mass exploits targeted deactivated plugins
Enable auto-updates for plugins and themesThe window between a public advisory and mass exploitation is often hours, not days
Prefer plugins with recent releases and a visible maintainerAbandoned plugins never get patched; the vulnerability stays forever
Set DISALLOW_FILE_EDIT in wp-config.phpRemoves the in-browser code editor, so a stolen admin session cannot write PHP
Keep a single default theme plus the one in useReduces surface and keeps a fallback for troubleshooting

Files WordPress leaves lying around

Because wp-config.php lives in the web root, any copy of it with a different extension is served as text: wp-config.php.bak, wp-config.php~, wp-config.php.old, wp-config.txt. A scanner checks these under Exposed backup or archive files. Enabling WP_DEBUG_LOG writes to /wp-content/debug.log, which is public by default and contains file paths, database errors and occasionally query contents. Directory listing under /wp-content/uploads/ or /wp-content/plugins/ exposes every file name, reported under Directory listing enabled. Move wp-config.php one directory above the web root, which WordPress supports natively, set WP_DEBUG false in production, and add the deny rules from Exposed Files.

# nginx: WordPress-specific denials
location ~* /wp-config\.php(\.|~|$) { deny all; return 404; }
location = /wp-content/debug.log { deny all; return 404; }
location ~* /(readme|license)\.(html|txt)$ { deny all; return 404; }
location ~* /wp-content/uploads/.*\.php$ { deny all; return 404; }   # no PHP execution in uploads

The login page and the rest of the surface

/wp-login.php and /wp-admin/ are at the same path on every site, so they receive constant automated login attempts. Renaming them is security through obscurity and breaks plugins; better controls are enforcing two-factor authentication for every account that can publish or administer, rate-limiting the login endpoint at the web server or with a plugin, restricting /wp-admin/ to known IP addresses where the team allows it, and making sure the passwords are not in any breach. Security headers apply to WordPress like any other site and are usually absent by default: a Content Security Policy is hard to write for a plugin-heavy site, but X-Frame-Options, X-Content-Type-Options, Referrer-Policy and HSTS are safe to add immediately, as described in HTTP Security Headers Explained.

Keeping the site current without breaking it

The objection to auto-updates is that a plugin update can break the site. That is true, and the alternative is worse: a site that is not updated will be compromised by a plugin vulnerability with a public exploit, and cleaning that up costs far more than a broken layout. Reduce the risk instead of avoiding the update. Keep a staging copy and let auto-updates run there a day ahead of production; take a backup before each update cycle, automatically; keep the plugin count low enough that you actually know what each one does; and read the changelog of anything that touches security or payments. WordPress core already applies minor security releases on its own, and since version 5.5 the dashboard can do the same for plugins and themes individually. Enable it for every plugin whose maintainer has a track record of releases, and replace the ones that do not.

Reading a WordPress scan result

The Scan.now website scanner identifies WordPress from the page, then runs the checks above alongside the generic ones: it requests xmlrpc.php and the users endpoint with plain GET or POST requests and reads the response, it fetches a small list of well-known backup and debug paths, and it parses versions from the HTML. It does not attempt a login, does not send system.multicall payloads and does not test plugins for exploitable behaviour; a WordPress-specific active scanner such as WPScan does those things and needs the site owner's permission. What the passive scan gives you is the attacker's reconnaissance, which is enough to act on. Triage in this order: exposed configuration or backups, outdated plugins with published vulnerabilities, xmlrpc and user enumeration, version disclosure, headers. The reasoning behind that ordering is in How to Read a Security Scan Report, and the wider context in the website security hub.

Our position on WordPress is that the platform is fine and the defaults are not. A site with auto-updates on, xmlrpc off, the users endpoint restricted, fewer than ten plugins that are all maintained, two-factor authentication on every admin and the deny rules above is a hard target. Most sites have none of those, and they are compromised by tooling that could not get past any one of them.