What does your WordPress site tell a stranger about itself?
Enter an address. This reads what the site already publishes to anyone who asks — the version, the usernames, the endpoints that are answering, and the security headers a browser acts on. No signup, no email required.
Every test, explained
Two halves: what a WordPress install publishes about itself, and the security headers your server sends alongside it. Plus what this tool refuses to do, and why that costs findings.
Whether this is WordPress, and what happens when it is not
The check reads your home page and looks for the things WordPress leaves behind: asset addresses under /wp-content/ or /wp-includes/, a generator tag, and the REST API link that core adds to every page.
Any one of those is enough. That matters, because a hardened site may have removed two of the three, and a check needing all of them would report the most careful installs as not WordPress at all.
If nothing matches, the WordPress tests are skipped and the page says so. The header tests still run, because those apply to any site whatever it is built on.
The version WordPress publishes about itself
By default WordPress prints its exact version into a meta tag on every page. It also appends that version to the address of every core stylesheet and script, as a ?ver= string.
That second one is the one people miss. Removing the meta tag feels like the whole job, and afterwards every core asset is still carrying the number.
Hiding it patches nothing, and it is not theatre either. When a vulnerability is disclosed, mass scanning begins within hours, and the way it works is reading this number off millions of sites and keeping the ones that match. Being unreadable does not make you safe. It takes you out of the list while you update, which is the only window that matters.
The two ways a site gives away a username
A WordPress login is a username and a password. Most attention goes on the password. But an attacker who does not know the username has to guess both, and the moment the username is known, half the work is gone.
The first route is the REST API. Asking /wp-json/wp/v2/users returns every author on the site, with their login slugs, and it answers that without anyone logging in.
The second is older and survives on sites that closed the first. Asking for /?author=1 redirects to that author’s archive, and the address of the archive is built from their user slug, which by default is their login name. Counting up from 1 walks the whole user table.
The tool reports the count and the mechanism. It does not print the names it found, because publishing somebody’s administrator login onto a web page would be a strange thing for a security tool to do.
xmlrpc.php, and why it is worse than it looks
This is the old remote interface. Jetpack uses it, the WordPress mobile apps use it, and pingbacks run over it. Plenty of sites use none of those and still have it answering.
It earned its reputation through system.multicall, which once let an attacker pack many login attempts into a single HTTP request. Core has since hardened that specific trick, so treat the older advice with some care.
What has not changed is the shape of the thing. It is a second authentication endpoint, sitting outside whatever protects your login page — so the login plugin counting failed attempts on wp-login.php is often not watching here at all. The pingback method has also been used to make other people’s servers issue requests on an attacker’s behalf.
If nothing you run needs it, the sensible position is that a login endpoint you do not use should not be reachable.
The tool asks for the file with a plain GET and reads the status code. It never posts anything to it, so no method is ever called and no login is ever attempted.
The login page, and honest expectations about moving it
The tool checks whether /wp-login.php answers, and whether what comes back is really a login form rather than a styled 404 returning 200.
This is reported as information, not as a fault, because moving the login page is not security. Anyone determined will find it again.
What moving it does is remove the background noise. Automated attempts against the default address run around the clock on almost every WordPress site, and they bury the one attempt that was actually worth looking at. The protection that holds is rate limiting on failed attempts, plus two-factor for anyone who can publish.
PHP errors printing onto live pages
The tool reads the page source for PHP error output — a warning, a notice, a deprecation, a fatal. It only counts one when the full shape is there, including the absolute file path and the line number, so an article about PHP errors does not trip it.
The problem is not the error. It is the path. Error output names the exact directory your site runs from, often the account it runs as, and usually the plugin that broke — a map that would otherwise take work to draw.
It is also read by search engines, which is how error text ends up in results pages.
The uploads folder, and directory listings
When a web server is asked for a directory with no index file, it either refuses or prints a browsable list of everything inside. WordPress does not decide this. The web server does.
On the uploads folder, a listing means every file ever uploaded is readable in order, including the ones never linked from any page. Draft images, client documents and the PDF somebody uploaded and then thought better of are all sitting there, and “nothing links to it” was the only thing protecting them.
This is checked only at the wp-content address your own pages published. If the page does not name one, the check does not run rather than guess.
Plugin and theme versions in your page source
Every stylesheet and script a plugin loads carries the plugin’s folder name in the address, and usually its version in a ?ver= string. So the page source is an inventory.
That is the same matching exercise as the core version, run against plugins — and plugins, not core, are where most WordPress compromises begin.
It is reported as information rather than as a fault, deliberately. Stripping the version breaks cache busting, so visitors keep stale CSS after every update. The real answer is keeping plugins current and deleting the ones you stopped using, which is also the only one that addresses the actual risk.
Content-Security-Policy, the one that is genuinely hard
A Content-Security-Policy tells the browser where scripts, styles and frames may come from. Without one, any script that reaches your pages runs with full rights — through a compromised plugin, a hijacked advertising tag, or a field that escapes its own escaping.
Two failure modes get reported besides absence. A policy sent only as Content-Security-Policy-Report-Only blocks nothing at all; it is the rehearsal header, and a site running only that one is as exposed as a site with no policy. And a policy whose script rules allow unsafe-inline still permits the injection it was added to stop.
WordPress makes that last one genuinely difficult, because core and most plugins print inline scripts. So the honest fix is nonces or hashes, not deleting the value and hoping. This is the one header worth staging carefully.
The rest of the header family
X-Frame-Options, or the CSP frame-ancestors directive that replaced it, stops another site loading your pages inside an invisible frame and putting its own buttons over yours. Either one is enough, so the tool accepts whichever is present. It also flags values browsers ignore, like ALLOW-FROM, which read as protection while providing none.
X-Content-Type-Options: nosniff stops a browser second-guessing the type of a file. An upload stored as an image and sniffed as HTML executes as HTML.
Referrer-Policy decides how much of the current address gets sent to other sites. Absence is reported as information only, because current browsers already default to something sensible. A policy explicitly set to unsafe-url is reported as a warning, because that is a site undoing the safe default and leaking full URLs.
Permissions-Policy decides which browser features your pages and anything embedded in them may ask for. Most sites need none of them.
Finally, the tool reads the Server and X-Powered-By headers. A bare product name is not a finding. A version number is, on the same reasoning as the WordPress version.
What this tool deliberately will not do
Every address it requests is either one WordPress publishes as part of its own documented interface, or one your page named first. Nothing is guessed at.
So there is no probe for readme.html, no attempt at wp-config.php.bak and its variations, no hunt for debug.log, and no directory listing check at an address your site did not publish. No password is tried and no form is submitted.
That rule costs real findings, and it is worth saying which. An exposed debug.log is invisible to this check. So is a database export left in the webroot under a name nobody would guess.
The trade is deliberate. This runs from a public form against a server somebody else owns, and a security tool that starts guessing at filenames is doing reconnaissance whatever its intentions are. A scan you would be comfortable finding in your own access log is worth more than two extra findings.
What usually goes wrong, and how it gets fixed
The same handful of causes turn up again and again. Here they are, with the change that fixes each one, and the detail that catches people out along the way.
Your usernames are public
Close both routes, because they are independent. Sites that fixed one and not the other are still exposed.
First, drop the users endpoint from the REST API for anyone not logged in. This leaves the rest of the API working, which matters — the block editor, and plenty of themes and forms, depend on it.
// Anonymous requests lose the users endpoint. Everything else still works.
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;
});
Now the author URLs. Here is the detail worth knowing: the archive address is built from the user’s slug, not directly from the login name — WordPress just derives the slug from the login name when the account is created. So the clean fix is to change the slug. Edit each user and set a nicename that is not the login, and the leak closes with author archives still working. Redirecting /?author= to the home page also works, but only if you genuinely do not use author archives — on a multi-author site it removes pages people link to.
Your WordPress version is on show
The generator tag comes off in two lines. Put them in a small plugin rather than in the theme, so a theme update does not undo it.
remove_action('wp_head', 'wp_generator');
add_filter('the_generator', '__return_empty_string');
That leaves the ?ver= string on core assets, which still names the version. You will find snippets that strip ver from every script and stylesheet, and they work — but they also break cache busting, so visitors keep serving stale CSS after each update. That is usually a bad trade for a site with any traffic. If it matters enough, replace the version with a value you control and change it on every deploy, rather than removing it.
xmlrpc.php is answering and nothing uses it
Check first. Jetpack, the WordPress mobile app and some backup and migration plugins all speak to it, and blocking it will break them without a clear error message.
If nothing needs it, block it at the web server rather than in PHP. A request that never reaches WordPress costs nothing.
# Apache — in the vhost, or in .htaccess at the site root
<Files "xmlrpc.php">
Require all denied
</Files>
# nginx
location = /xmlrpc.php {
deny all;
}
If you do need it for one service, allow that provider’s addresses and deny everything else, rather than leaving it open to the internet.
PHP errors are printing onto the page
Two layers, and the second is the one that actually holds.
In wp-config.php, turn display off and logging on. Note where the log goes — the default is wp-content/debug.log, which is inside the webroot and readable by anyone who asks for it. Point it somewhere else.
// wp-config.php — above the "stop editing" line
define('WP_DEBUG', true);
define('WP_DEBUG_DISPLAY', false);
define('WP_DEBUG_LOG', '/home/you/logs/wp-errors.log'); // OUTSIDE the webroot
@ini_set('display_errors', '0');
# php.ini or .user.ini — this is the layer that covers everything
display_errors = Off
log_errors = On
The server-level setting is the one that matters. Turning off WP_DEBUG_DISPLAY alone stops WordPress printing errors and leaves everything else on the account still doing it — a stray script, an old installer, anything outside WordPress. Set it at the server and the whole account is covered whatever any single application is configured to do.
Your uploads folder lists its contents
This is a web server setting. One directive, applied at the site root, and it covers every directory rather than just this one.
# Apache — vhost or .htaccess
Options -Indexes
# nginx — this is already the default, so check for an explicit override
autoindex off;
Dropping an empty index.php into the folder is the advice you will find most often. It does work, and it only fixes the one directory you remembered. Turn indexes off properly instead.
The security headers are missing
These belong in the server configuration, not in a plugin. Set there, they survive every plugin change, theme swap and reinstall, and they apply to files a plugin never sees.
Start with the four that have no real downside. On Apache this needs mod_headers enabled.
# Apache
Header always set X-Content-Type-Options "nosniff"
Header always set X-Frame-Options "SAMEORIGIN"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set Permissions-Policy "camera=(), microphone=(), geolocation=()"
# nginx
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
Leave Content-Security-Policy until last, and stage it. Publish it as Content-Security-Policy-Report-Only first, watch what the browser console reports for a week or two, and only then switch the header name. Applying a strict policy straight to a live WordPress site usually breaks the admin bar, the editor and half the plugins at once. On nginx, remember that add_header in a nested block silently discards every header set in the block above it.
Found something broken?
Send me the result. I'll tell you what it takes to fix it, and whether it's worth paying anyone to do — including me.
Prefer to talk? Book a free call ↗ · Or hire me on Upwork ↗ · Typical reply within one business day.
Questions
Does running this change anything on my site?
Why does it not look for readme.html, backup files or an exposed debug.log?
debug.log or a forgotten database export stays invisible here. That is the correct trade. A scan you would be comfortable finding in your own log is worth more than two extra findings.Is hiding the WordPress version actually worth doing?
Will closing the username exposure break my site?
/?author= away also works, but it removes those archive pages, so only do that if nothing links to them.