WordPress Hardening: I Measured The Advice Before Following It.

I applied the most-repeated WordPress hardening tip and measured it. The version was still readable on every stylesheet URL on the page.

Aditya Sharma·Updated ·9 min read

Short answer: WordPress hardening checklists are usually unordered, and order is the only thing that matters. Three items remove an attacker’s options. Several make life mildly harder. At least one does nothing at all, and it sits near the top of most lists.

I know which is which because I applied them to a clean install and measured from outside.

The tip that does not work

Every “harden WordPress” article includes removing the generator meta tag so nobody can see your version. I applied the standard snippet and measured what was still readable.

Table of version leak points before and after applying the generator-hiding snippet. The meta generator tag was not present either way. The OPML generator attribute went from WordPress 7.1 to not exposed. The question-mark-ver parameter on every enqueued asset still reads 7.1 after the snippet. readme.html still returns HTTP 200
The exact core version was still on every stylesheet and script URL on the page, stable

across three fetches.*

Where the version can leakBeforeAfter
not presentnot presentn/a
OPML generator attributeWordPress/7.1not exposedfixed
?ver= on every enqueued asset7.17.1still leaking
/readme.htmlHTTP 200HTTP 200still public

The snippet works, in the narrow sense that it removes the thing it targets. It just does not achieve the goal, because WordPress appends ?ver=7.1 to every enqueued asset and that is right there in the page source.

I will be fair to the advice on one point. Modern readme.html no longer states the WordPress version, only PHP and MySQL requirements. So that row is an exposed file rather than a version leak, and I would still remove it, for tidiness rather than security.

But the deeper problem is the premise. Scanners do not read your meta tag. They fingerprint by hashing known core assets, which works whether or not you removed anything. Hiding the version costs you an afternoon and changes nothing an attacker does.

Reproducing the version-leak finding on a busier, more realistic install

I re-ran the same measurement against a second WordPress install while expanding this post, one running WooCommerce, Elementor, Contact Form 7 and an optimization plugin that combines and caches most enqueued assets, specifically to see whether a more realistic, actively-optimized site closes the gap the original test found on a near-bare install.

curl -s https://example.com/ | grep -oE '(wp-includes|wp-admin)[^"]*ver=[0-9.]+'
wp-includes/js/wp-emoji.js?ver=7.1
wp-includes/js/twemoji.js?ver=7.1

The asset optimizer bundled and cache-busted almost everything else on the page, WooCommerce’s own stylesheets, Contact Form 7’s, the block library CSS, all serving from cached, renamed files with no version string visible at all. Two files slipped through anyway: WordPress core’s own emoji-support scripts, loaded directly rather than through whatever pipeline combined the rest, still carrying ?ver=7.1 in plain sight. Even a site actively optimizing its asset delivery, which is exactly the kind of site that looks hardened at a glance, leaked the same core version this post’s original, much simpler test found.

The generator tag on this install went further than the original example too, three separate tags rather than one: WordPress 7.1, WooCommerce 11.1.0, and a detailed Elementor 4.2.4 tag naming specific enabled features and settings. None of that is hidden by the standard version-hiding snippet, which targets only WordPress core’s own tag and leaves every plugin’s separate generator tag untouched, a second, compounding gap in the same advice this post already calls theatre.

What a fresh install actually ships with

Before ranking anything, I wanted to know the starting position rather than assume it.

Table of defaults on a clean WordPress 7.1 install. DISALLOW_FILE_EDIT not defined, DISALLOW_FILE_MODS not defined, FORCE_SSL_ADMIN false, XML-RPC enabled with 80 methods including pingback.ping and wp.getUsersBlogs, readme.html returns HTTP 200, and automatic core updates enabled
Automatic core updates is the one secure default in the table, and it is the most valuable

one on the list.*

SettingDefaultWhat that means
DISALLOW_FILE_EDITnot definedplugin and theme editors are live in wp-admin
DISALLOW_FILE_MODSnot definedplugins can be installed from the dashboard
FORCE_SSL_ADMINfalseadmin sessions are not forced to HTTPS
XML-RPCenabled80 methods, including pingback.ping and wp.getUsersBlogs
/readme.htmlHTTP 200publicly readable
automatic core updatesenabledthe one secure default in this table

The XML-RPC number surprised me. I sent a system.listMethods call and counted 80 methods available without authenticating, including pingback.ping and wp.getUsersBlogs. That second one is the method that makes XML-RPC useful for credential guessing at volume, and the fuller version of that story is worth reading before you disable anything, because plenty of sites still need it.

None of these defaults are bugs. They are choices made for somebody installing their first site. An agency running forty client sites is a different user, and most of what “hardening” means is closing that gap.

The WordPress hardening checklist, in order

Ranked hardening checklist. Keeping core plugins and themes updated, strong unique credentials with 2FA, and removing unused plugins and themes are high value. Disabling XML-RPC if unused, blocking username enumeration and DISALLOW_FILE_EDIT are medium. Hiding the WordPress version has no value
The bottom row appears near the top of most checklists on the web.
ItemValueWhat it does
keep core, plugins and themes updatedhighremoves known exploits entirely
strong unique admin credentials, 2FAhighdefeats credential stuffing
remove unused plugins and themeshighshrinks the attack surface
disable XML-RPC if unusedmediumcloses 80 methods, check Jetpack and mobile app first
block username enumerationmediumremoves a free list of valid logins
DISALLOW_FILE_EDITmediumpost-compromise containment, cheap
hide the WordPress versionnonechanges nothing an attacker does

The top row is the whole game and it is boring. Updates close vulnerabilities rather than obscuring them, and no amount of hardening below compensates for skipping it. If you do one thing from this post, make it a repeatable update process you actually run.

The third row is worth a sentence because it is widely misunderstood. Deactivating a plugin does not remove its code. The files stay on disk and remain reachable. A vulnerability in a deactivated plugin is often still a vulnerability, which is a different problem from the plugin being gone.

The two medium rows about XML-RPC and username enumeration belong together. Enumeration hands over a list of valid usernames, XML-RPC provides a way to try passwords against them quickly. Either one alone is a nuisance. Both together is a working attack.

DISALLOW_FILE_EDIT deserves defending, because it gets dismissed as pointless on the grounds that an attacker with admin access has already won. That is true and it is not the point. The constant turns “attacker logs in and pastes PHP into the theme editor” into “attacker logs in and has to find another way to write a file”. It costs one line in wp-config.php, it breaks nothing that a client should be doing anyway, and it makes the fast, easy version of a compromise unavailable. Cheap containment is still containment.

What the same checks found on our own sites

Table of findings across six of our own sites. Usernames readable over REST on 3 of 6 with two sites exposing 10 accounts each. DMARC not fully enforcing on 4 of 6. No Content-Security-Policy on 5 of 6. No Referrer-Policy on 5 of 6. No CAA record on 5 of 6. No DNSSEC on 6 of 6
Published rather than quietly fixed first, because a hardening post written from invented

examples is worth nothing.*

FindingSitesDetail
usernames readable over REST3 of 6two sites exposed 10 accounts each
DMARC not fully enforcing4 of 6two at pct=5, one at p=none, one with no record
no Content-Security-Policy5 of 6a project, not a checkbox
no Referrer-Policy5 of 6one header, genuinely a checkbox
no CAA record5 of 6one DNS record
no DNSSEC6 of 6a decision nobody has made

Two of our sites hand out ten usernames each to anyone who asks. That is not a hypothetical from a checklist, it is our estate, found by running the checks in this post against ourselves while writing it.

The bottom four rows are portfolio-level decisions rather than per-site findings, and I have written about why that distinction changes what goes in a client report. DNSSEC on six of six sites is not six problems. It is one decision nobody has made.

Re-checking our own portfolio table found one row moved, five did not

Every one of the six findings in this post’s portfolio table was re-checked across this blog’s own expansion work, spread across several separate posts rather than one sitting, and it is worth pulling the results together here since this is the post that first named them together.

FindingOriginalRe-checked
usernames readable over REST3 of 6unchanged, still 3 of 6, one specific site fixed and one newly found
DMARC not fully enforcing4 of 6unchanged, same four, same pct=5 figures
no Content-Security-Policy5 of 6unchanged
no Referrer-Policy5 of 6unchanged
no CAA record5 of 64 of 6, sitea.example completed its record
no DNSSEC6 of 6unchanged, still zero

One real move: sitea.example‘s CAA record went from incomplete to fully specified, already documented in the nameserver diversity post. The REST-enumeration row held its raw count but not its composition, sitea.example closed its own exposure while a domain not in the original six, siteb.example, turned up open during a different post’s re-check, netting out to the same ratio for a different reason entirely. Everything else sat exactly still.

That mix, one clear fix, one lateral move that looks unchanged but is not, and four rows frozen solid, is a more honest picture than either “nothing improved” or “it’s all been fixed” would be. It is also the practical argument for treating a portfolio table like this one as a living document rather than a snapshot from the day it was published: the aggregate ratio can stay identical while the specific sites behind it change underneath it, and only re-running every check individually, not just re-reading the summary row, would have caught that.

How I would run this

Take the top three rows, apply them to every site you look after, and do not move on until they are genuinely done everywhere. Then take the middle three per site, with the XML-RPC check done properly because disabling it breaks real integrations. Skip the bottom row entirely.

That is a shorter list than most checklists and it is ordered, which is the part that makes it usable on a Tuesday afternoon with three client sites open.

One caution on sequencing. Do not start with the middle three because they feel more like security work. Disabling XML-RPC on a site running six month old plugins is rearranging furniture in a house with the front door open, and it produces the worst outcome available: a site that is no safer and a client who believes it is.

Protuno’s security agent, Rook, carries six playbooks today: WP Security Audit, Plugin Vulnerability Checker, WP Malware Scan, New Admin Watch, Comment & Signup Spam Exposure Audit and WP Hardening. Only WP Hardening can change a site. Rook is live now.

If you want a five minute version of this, request /wp-json/wp/v2/users on your own site and see what comes back. That single request answered more for me than the last three hardening articles I read, and it needs nothing but a browser.

Then do it for every client site in one sitting. The value is in the comparison. A finding on one site out of forty is a site problem with a specific cause you can go and find. The same finding on all forty is a standard you never set.

Comments