Cover graphic with the kicker WordPress .htaccess, read from the origin log, September 2026, and the headline The deny rule worked. Every request it denied rendered a 141 KB page. Subtitle: 4,659 direct requests for core include files in nineteen days, 1,451 fatal; the block that stops them, the cPanel default that turned each 403 into a 1.7 s WordPress 404, and the line that makes a deny cost 9 bytes. Three figures: 4,659 direct requests for wp-includes and wp-admin/includes PHP files; 1.7 s per denied request, WordPress rendered Page not found for each; 9 bytes, what a denied request costs now, PHP never starts. Footer: mxchat.ai, origin access logs and PHP error log read 19 September 2026, WordPress 7.1 on cPanel, Apache, PHP 8.2.

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.

Horizontal bar chart titled 4,659 direct requests for core include files, by what they got, covering every request for a PHP file under wp-includes or wp-admin/includes that one WordPress origin logged from 31 August to 18 September 2026 on ports 80 and 443; not one came from a browser with a page on the site as its Referer. Bars: 200 OK 3,076 or 66.0 percent, a class file declares itself and exits, nothing else loads; 500 Server Error 1,451 or 31.2 percent, parent class not loaded, fatal error, one entry in the PHP error log; 404 Not Found 69 or 1.5 percent, a file that is not there, a full WordPress 404 render; 403 Forbidden 31 or 0.7 percent, blocked by the firewall plugin, a 7 KB page, PHP ran; 301 Moved 21 or 0.5 percent, index.php and a few others redirected by WordPress; 401 Unauthorized 11 or 0.2 percent. Footer: port 80 carried 4,382 of the 4,659, 94 percent; 1,088 distinct paths; 26 addresses on port 80 and 16 over HTTPS; two addresses made 2,637 of the requests, 57 percent, between them.

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.

Vertical bar chart titled Direct core-file requests per day: two addresses, two days, 52 percent, showing requests for wp-includes and wp-admin/includes PHP files at the origin by day on both ports from 31 August to 18 September 2026. Blue bars: 421, 27, 515, 90, 0, 1, 526, 74; a red bar on 8 September at 1,424; 173, 11, 0; a red bar on 12 September at 1,002; 144, 77, 83, 16, 75, 0. Subtitle: 8 September is one address, 146.70.194.252, 1,348 in the window, and 12 September is another, 91.224.92.159, 1,289; fatal entries in the PHP error log follow the same shape, 2,165 on 8 September. Footer: the other seventeen days run 0 to 526; every request in the chart is a wordlist scanner reading the file tree; nothing on the site links to any of these paths and the theme's HTML references none of them.

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.

ConfigurationResponseBytesTimeWhat ran
No deny rule, existing class file200011 msPHP ran one file; WordPress never started
No deny rule, file whose parent class is not loaded500011 mssame, plus about 300 bytes appended to the PHP error log
No deny rule, a file that does not exist404141,8691,826 msthe full WordPress bootstrap and the theme’s 404 template
Deny rule, cPanel’s default ErrorDocument404141,8691,760 msthe full WordPress bootstrap and the theme’s 404 template
Deny rule + ErrorDocument 403 "Forbidden"403910 msApache; 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.

Horizontal bar chart titled What one direct request for a core include file cost, the median of three timed requests at the origin with curl time_total and Cloudflare bypassed on 19 September 2026: wp-includes/PHPMailer/POP3.php for the existing-file rows, wp-includes/widgets/class-wp-widget-block.php for the fatal row, a made-up name under wp-includes for the missing-file row. Bars: existing class file with no deny rule 11 ms, 200, 0 bytes, PHP runs one file, declares a class, exits; class file whose parent is not loaded 11 ms, 500, 0 bytes, plus about 300 bytes written to the PHP error log; a file that does not exist 1,826 ms, 404, 141,869 bytes, a full WordPress Page not found render; deny rule plus cPanel's default ErrorDocument 1,760 ms, 404, 141,869 bytes, the 403 becomes /403.shtml, not there; deny rule plus ErrorDocument 403 Forbidden 10 ms, 403, 9 bytes, Apache answers, PHP never starts. Footer: the deny rule on its own made the cheap case 160 times more expensive; the ErrorDocument line is what makes a deny free, and it applies to every deny already in the file, xmlrpc.php, the IP blocks, the directory listings.

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 setCoversOf the 4,659 requestsLets through
Handbook block (^wp-includes/[^/]+\.php$, langs, theme-compat, wp-admin/includes/)root-level files only2,832 (60.8%)1,827: every subdirectory
This site’s block (^wp-includes/.+\.php$, wp-admin/includes/)every PHP file at any depth, one exception4,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

  1. 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.
  2. 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.php line above.
  3. 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.
  4. 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 under wp-includes/ stops answering the way it did before.
  5. 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/.

Similar Posts