WordPress .htaccess: Each Deny Rule Was a 1.7 s Page Render
The WordPress hardening guide has a short block of .htaccess rules that stops anyone from loading the files under wp-includes/ directly. I added a version of it to this site this morning, verified that the files now returned an error instead of a 200, and moved on. Then I timed one of the denied requests. Before the rule, a request for wp-includes/PHPMailer/POP3.php cost the server 11 milliseconds. After the rule it cost 1.76 seconds and 141,869 bytes, because the 403 the rule produced was being handed to WordPress, which rendered the theme’s Page not found for it. The deny rule worked. It also made every denied request 160 times more expensive than the thing it denied.
This post is the whole chain: what the origin logs show being requested (4,659 direct requests for core include files in nineteen days, 1,451 of them fatal errors), what each of those requests actually costs in the five configurations I measured, the cPanel default that turns an Apache 403 into a WordPress 404, and the one line that makes a deny cost nine bytes. The site is WordPress 7.1 on cPanel with Apache and PHP 8.2, the same install as the port-80 log and OPcache posts. Every number is from its own logs, and the probe that found the mechanism is short enough to run on yours.
What gets requested: 4,659 core include files in nineteen days
The population is every request whose path is a PHP file under wp-includes/ or wp-admin/includes/, in both origin access logs (port 80 and port 443) from 31 August to 18 September. None of these files is meant to be requested by a browser. They are included by wp-load.php once WordPress has started; requested on their own, a class file declares a class and exits, and a file that extends a class which has not been loaded dies with a fatal error. Nothing on the site links to any of them; the rendered HTML of the front page references no PHP file under wp-includes/ at all.

Two thirds got a 200 with an empty body. A third got a 500, which is the interesting third: each of those is a fatal error, and each fatal error is an entry in the PHP error log. That log is 27 MB on this host and holds 3,295 fatal entries dated September. The class file the scanners like most, wp-includes/widgets/class-wp-widget-block.php, appears in it 258 times over the life of the log, every time with the same message: Class “WP_Widget” not found. It is not a bug. It is what the file does when you run it alone.
Ninety-four percent of the requests arrived on port 80, which is why they were easy to find: plain HTTP on this site carries almost nothing legitimate, so a scanner reading the file tree is the whole log. The same requests over HTTPS are buried under 215,000 lines of real traffic. That is the same lesson as last time, and it held again: read the small log for the class, then count the class in the big one.

The shape is two sweeps. One address made 1,348 of these requests, almost all on 8 September, walking most of the 1,088 distinct paths the window contains; another made 1,289, mostly on the 12th. The error log has the matching spike: 2,165 fatal entries on the 8th, against a median of about 30 on the other days. If your error log is large and you have never read it, this is the first thing to check, because it is the one class of noise that grows without anyone visiting your site.
What one of these requests costs, measured five ways
The cost is the part the hardening guides do not measure, and it is not what I expected. I timed the same request at the origin, bypassing Cloudflare, three times per configuration, and took the median.
| Configuration | Response | Bytes | Time | What ran |
|---|---|---|---|---|
| No deny rule, existing class file | 200 | 0 | 11 ms | PHP ran one file; WordPress never started |
| No deny rule, file whose parent class is not loaded | 500 | 0 | 11 ms | same, plus about 300 bytes appended to the PHP error log |
| No deny rule, a file that does not exist | 404 | 141,869 | 1,826 ms | the full WordPress bootstrap and the theme’s 404 template |
Deny rule, cPanel’s default ErrorDocument | 404 | 141,869 | 1,760 ms | the full WordPress bootstrap and the theme’s 404 template |
Deny rule + ErrorDocument 403 "Forbidden" | 403 | 9 | 10 ms | Apache; PHP never started |
Read the first two rows again. A direct load of an existing core file is nearly free. PHP-FPM starts, runs one file, and exits; WordPress is never loaded, no query runs, nothing is rendered. On a host where a normal page costs 1,650 ms of PHP because OPcache is not installed, the file-tree scanners were the cheapest visitors the site had. The expensive request in the population is the miss: a path under wp-includes/ that does not exist falls through to WordPress, which boots completely to tell the scanner it is lost. There were 69 of those in the window.

Which is what the fourth row says the deny rule did to all of them. With the block in place and nothing else changed, every one of the 4,659 requests would have become a 1.7-second WordPress render. The rule closed a disclosure and opened a cost.
The block, and why it is wider than the handbook’s
The hardening guide’s rules deny wp-admin/includes/, the PHP files in the root of wp-includes/, the TinyMCE language files and theme-compat/. They do not touch subdirectories, and the scanners spend a lot of their time in subdirectories: sodium_compat/ (208 requests on port 80), SimplePie/ (165), widgets/ (150), php-ai-client/ (146), ID3/ and Requests/ (130 each), rest-api/ (115), PHPMailer/ and fonts/ (97 each). Against this log, the handbook block would have caught 2,832 of the 4,659 requests and let 1,827 (39%) through.
| Rule set | Covers | Of the 4,659 requests | Lets through |
|---|---|---|---|
Handbook block (^wp-includes/[^/]+\.php$, langs, theme-compat, wp-admin/includes/) | root-level files only | 2,832 (60.8%) | 1,827: every subdirectory |
This site’s block (^wp-includes/.+\.php$, wp-admin/includes/) | every PHP file at any depth, one exception | 4,657 (99.96%) | 2 requests for wp-tinymce.php, deliberately |
The one exception is wp-includes/js/tinymce/wp-tinymce.php. Its header says it has not been used by core since 5.1 and is kept “as a back-compat for plugins that may be using this method of loading directly”, so a plugin on some site somewhere still requests it. No plugin on this site references it, and the log has two requests for it in nineteen days, both from the 8 September sweep, each answered with the 370 KB TinyMCE bundle the file exists to serve; it stays reachable because that is what the file asks for and blocking it saves almost nothing. ms-files.php is inside the block, which is fine on a single site and would not be on multisite, where it serves uploads.
Before adding a broader rule than the guide’s, I checked the thing the guide cannot check for you: whether anything legitimate on this install loads a file under wp-includes/ directly. The test is the HTTPS log, not the port-80 one, because that is where real visitors are. Every request in the population with a 200, a browser user agent and a page on this site as its Referer: zero. Every request with any user agent and a site Referer: zero. The rendered front page references no PHP path under wp-includes/. That is the check; if it comes back non-zero on your site, the paths it names are your exceptions.
# BEGIN BLOCK CORE INCLUDE FILES
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule ^wp-admin/includes/ - [F,L]
RewriteRule ^wp-includes/js/tinymce/wp-tinymce\.php$ - [L]
RewriteRule ^wp-includes/.+\.php$ - [F,L]
</IfModule>
# END BLOCK CORE INCLUDE FILES
It goes above the # BEGIN WordPress block, which is where the guide puts its version too. The order matters: WordPress’s own rules send every path that is not a real file to index.php, and a real file under wp-includes/ passes those rules untouched, so the deny has to come first. Static files are unaffected; the pattern ends in \.php$, and wp-includes/js/jquery/jquery.min.js still returns 200 through the CDN.
The 403 that rendered a 404
The deny rule returns a 403, and Apache does not send a 403 bare; it consults ErrorDocument 403 to decide what body to send with it. On a cPanel host that directive is set for you, in the virtual host, and on this one it points at /403.shtml. There is no 403.shtml in the document root. So Apache issues an internal redirect to a file that does not exist, WordPress’s rewrite rules catch the miss exactly as they catch any other missing URL, and WordPress renders Page not found with a 404 status, for a request that Apache had already decided to forbid.
I did not want to assert that from the timing alone, so I asked WordPress what it was rendering. A few lines in the child theme’s functions.php, removed the same minute, that log the request environment when is_404() is true:
REQUEST_URI=/wp-includes/PHPMailer/POP3.php
REDIRECT_STATUS=403
REDIRECT_URL=/403.shtml
SCRIPT_NAME=/index.php
REDIRECT_REDIRECT_STATUS=403
That is the whole mechanism in five variables. The original request, the 403 Apache assigned it, the error document it went looking for, and index.php, which is where a missing file ends up on a WordPress site. Two internal redirects, one bootstrap, 141 KB of HTML nobody will read, status 404. It applied to every Apache-level deny this site has ever had: the <Files xmlrpc.php> Require all denied</Files> block that every hardening guide recommends, the IP block list, the wordlist-path block, the Options -Indexes I added yesterday. The access log had been recording all of them as 404s with 141,869-byte bodies, which is why the port-80 post counted xmlrpc.php under 404 and not 403. Nothing was denied cheaply on this host, ever.
The fix is one line, above everything else in .htaccess:
ErrorDocument 403 "Forbidden"
A string rather than a path means Apache serves the string itself: no sub-request, no rewrite, no PHP. After it, the same denied request returns 403, 9 bytes, in 10 ms, and so does xmlrpc.php, and so does a directory listing, and so will anything else the file ever denies. Verified at the origin and through Cloudflare, which now passes the 403 through as a 403. If you prefer a page to a word, point the directive at a static HTML file that exists; what matters is that the target is served by Apache and never reaches index.php.
What this does not fix, honestly
The denies were a small share of the WordPress 404 renders in the window. Across both ports the site rendered roughly 20,000 full Page not found pages in nineteen days, about a thousand a day, at 1.7 seconds each: close to half an hour of PHP time per day producing HTML for wordlist scanners. Only about 400 of those were denies that the ErrorDocument line now answers in Apache. The rest are honest misses: /.env, /phpinfo.php, /graphql, /api/config, /ip (2,534 times, 2,278 of them in two days), which no rule catches because no rule names them. What has changed is the price of naming them. A deny rule used to cost the same as the miss it replaced; now it costs nine bytes, so a short block for the paths that appear most in your own log is worth writing where it was not before.
The other thing the block does not cover is plugin directories. The September error log breaks down as 1,288 fatal entries from files under the WooCommerce plugin, 1,118 under wp-includes/, 668 under WPForms Lite and 221 under wp-admin/includes/: today’s rule removes 41% of the log’s fatal entries and leaves the plugin ones. A blanket deny on wp-content/plugins/**.php is not safe to copy from a post, because some plugins serve PHP files directly on purpose, so that block needs the same legitimacy check per plugin, against your own HTTPS log, before it ships. That is a separate day. If you run MxChat, nothing in it is loaded by URL; every request it handles arrives through admin-ajax.php or the REST API, as the admin-ajax log showed, and the documentation never asks you to open a plugin file in a browser.
Doing this on your own site in ten minutes
- Count the population. In your origin access log (both files, if your host keeps a separate port-80 log), pull every request whose path matches
^/wp-includes/.*\.php$or^/wp-admin/includes/, and tally the status codes. If the 500s are numerous, open the PHP error log and confirm they are Class not found fatals from those same files. - Run the legitimacy check. From the same population, keep only 200s whose Referer is a page on your site. Expect zero. Anything that appears is a real dependency and becomes a pass-through rule like the
wp-tinymce.phpline above. - Time a denied request before you add the block.
curl -o /dev/null -w "%{http_code} %{size_download} %{time_total}\n"against a path your rule will deny. If the result after the block is a 404 with a body the size of your theme’s 404 page, you have the cPanel default, and step 4 is not optional. - Add
ErrorDocument 403 "Forbidden"at the top of.htaccess, then the block above the WordPress rules. Back the file up first, and write the check that restores the backup if the front page,wp-admin, the REST API or a static file underwp-includes/stops answering the way it did before. - Time the same request again. 403, a handful of bytes, single-digit milliseconds. Then time
xmlrpc.php, if you deny it, and enjoy the same number.
Frequently asked questions
Isn’t a direct load of a core file harmless if it costs 11 ms?
The cost was harmless; the disclosure is not. A 200 tells a scanner which files exist and, from the response, which WordPress version you run, and the 500s fill your error log with noise that hides real errors. The rule is still worth adding. The point of the post is that on a cPanel host the rule alone makes the cost worse, and the ErrorDocument line is the half of the fix nobody mentions.
Why does the hardening guide’s block only cover the root of wp-includes?
It plays safe by not reaching into subdirectories, which have at times held files meant to be requested, and it says itself that the root rule has to be dropped on multisite because of ms-files.php. On a current single-site install the only file under wp-includes/ still kept for direct requests is wp-tinymce.php, and the header inside it says so. Check your own log rather than trusting either the guide or this post: the legitimacy check in step 2 is the whole argument.
Does this apply if I’m not on cPanel?
The include-file block applies to any Apache host. The 403-to-404 cascade needs an ErrorDocument 403 that points at a path which does not exist in your document root; cPanel sets one, and other panels and some managed hosts do too. The REDIRECT_URL probe above answers the question for your host in one request. On nginx there is no .htaccess and the equivalent is a location block that returns 403 directly, which does not have this problem.
Will ErrorDocument 403 “Forbidden” break anything?
It changes what a forbidden request receives as its body and nothing else. Nothing in the admin screens requests a path these rules deny, WordPress’s own permission errors are generated by PHP with their own pages, and Cloudflare’s challenges are answered at the edge before the origin sees the request. The one thing you lose is a styled error page for scanners.
Should I also block the scanner wordlist paths, like /.env and /phpinfo.php?
After this change, yes, for the ones your own log shows. Before it, each of those denies cost as much as the WordPress 404 it replaced, so it bought nothing but a cleaner status column. Now it buys back 1.7 seconds of PHP per hit. Start with the top ten paths in your 404 list, and skip anything a browser or a search engine might legitimately ask for, like manifest.json or .well-known/.