Skip to content
System nominalUTC --:--:--Articles 080
MODULE 05 Lesson 13 / 18 40 MIN

Hardening a site

Configuration, File Permissions and HTTP Headers

What belongs in wp-config.php, what the web server will happily hand out, and which security headers WordPress 7.0 actually sends on its own.

An administrator finishes a hardening checklist from a blog post. HTTPS is on, the passwords are long, the site runs the current patch release. Then someone requests /wp-content/debug.log and gets a 200 with a full error log in text/plain: absolute server paths, a failed database connection string, and the username of a user that no longer exists.

Nothing on that checklist was wrong. The problem is that most hardening advice is organised by severity rather than by reachability. The configuration file and the filesystem are one system, and the only question that matters about a file is whether a request can return it.

A file the web server executes is not a file an attacker can read

PHP files in a WordPress tree are usually safe from direct download for a boring reason: the web server hands .php to the interpreter instead of returning the bytes. That is why wp-config.php at 644 does not leak its database password on a normal nginx or Apache setup, and why the endless argument about 400 versus 644 misses the point. Both are unreadable over HTTP. The permission that matters is the one that keeps other users on a shared machine out, and there 640 or 600 with the web user in the group is a better answer than a number copied from a forum.

What that rule does not protect is every other file type. A .log, .sql, .env, .bak or .zip sitting in the tree is a static file, and the web server returns it without asking WordPress anything. On the 7.0.7 install I used while writing this, with WP_DEBUG_LOG enabled, one request to /wp-content/debug.log returned the whole file:

curl -s -D - -o /dev/null http://127.0.0.1:8425/            # create a log entry
curl -s http://127.0.0.1:8425/wp-content/debug.log
HTTP/1.1 200 OK
Content-Type: text/plain; charset=UTF-8
Content-Length: 57

[16-Oct-2026 20:13:35 UTC] [lab-harness] init fired on /

The file was created 644, owned by the user who ran PHP. On a shared host that user is the same account that owns every site under your home directory, so the neighbours can read your log, and on a misconfigured host so can anyone with a URL.

The same reasoning applies to backups. A nightly wp-content/backups/ directory full of .sql files is a database dump with a public URL, and no amount of DISALLOW_FILE_EDIT changes that. Either write backups outside the document root or make the server refuse to serve the extension.

wp-config.php is a small file that should stay small

The file needs database credentials, the eight authentication constants, the table prefix, and your own flags. It does not need API keys for a payment provider or an SMTP password. Every secret added to it is one that a stray copy in a backup, or a single git add ., exposes along with everything else.

Secrets that a plugin needs belong in the environment or in a file outside the web root, read by a small loader:

// wp-config.php
$stripe_key = getenv( 'STRIPE_SECRET_KEY' );
if ( ! $stripe_key ) {
    $path = dirname( ABSPATH, 2 ) . '/secrets/stripe.key'; // outside the web root
    if ( is_readable( $path ) ) {
        $stripe_key = trim( file_get_contents( $path ) );
    }
}
define( 'STRIPE_SECRET_KEY', $stripe_key ?: '' );

Note what that code does not do: it does not fall back to a hard-coded default. A missing secret should make the feature refuse to run.

Where wp-config.php can live

WordPress will look one directory above the installation for wp-config.php, so a site in the document root can keep its configuration outside it. The Advanced Administration handbook notes that people disagree about the benefit and that moving it carelessly can introduce real problems. If you move it, move it one level and test the whole site, including WP-CLI.

Rotating the salts is a logout button, not a patch

The eight key and salt constants do not encrypt anything. They are the secret input to wp_hash(), which produces the value in the authentication cookie and the value inside every nonce. Change AUTH_KEY and every existing cookie stops validating, because the hash the browser presents can no longer be reproduced. That is the entire mechanism, and it is why rotating salts logs everyone out.

Two consequences follow, and they are the reason this is a response action rather than a hardening step. Rotating the salts invalidates application passwords too, so any integration authenticating with one breaks at the same moment. And it does not remove an attacker who has already uploaded a file; it only removes the credentials they hold. If you are rotating because you believe a session was stolen, rotate, then look for persistence with the techniques in module 6. Rotating salts without looking is how a compromise survives a "cleanup".

DISALLOW_FILE_MODS is a bigger hammer than its name suggests

The two constants look like a pair. They are not the same size. WordPress resolves edit_files, edit_plugins and edit_themes through map_meta_cap(), and when DISALLOW_FILE_EDIT is true those three map to do_not_allow. The file editor pages return 403, the administrator no longer has the capability, and nothing else changes.

DISALLOW_FILE_MODS feeds wp_is_file_mod_allowed(), which returns false for every context. That removes install_plugins, update_plugins, delete_plugins and their theme equivalents from every user, including administrators.

// wp-config.php, before the "stop editing" line
define( 'DISALLOW_FILE_EDIT', true );
define( 'DISALLOW_FILE_MODS', true );
$ wp eval 'foreach ( array( "edit_files", "edit_plugins", "edit_themes",
  "install_plugins", "update_plugins", "manage_options", "edit_theme_options" ) as $c ) {
  printf( "%-20s %s\n", $c, var_export( $user->has_cap( $c ), true ) ); }'
edit_files           false
edit_plugins         false
edit_themes          false
install_plugins      false
update_plugins       false
manage_options       true
edit_theme_options   true

Read the last two lines again. The file editor is gone and so is the plugin installer, but configuring the site is untouched.

The cost is operational, and it lands on whoever has to ship a plugin update on a Friday. With DISALLOW_FILE_MODS set, the dashboard's Updates and Add Plugins screens return 403, and automatic background updates stop before they start. An update now means a deploy: SSH, WP-CLI, Composer, whatever your pipeline is. Teams that already deploy that way lose nothing. Teams whose release process is clicking Update Now should enable DISALLOW_FILE_EDIT on its own for now.

One caveat from the lab, because it surprises people. WP-CLI's core update and plugin activate commands wrote to the filesystem on my test install even with DISALLOW_FILE_MODS defined as true. The constant governs WordPress's own checks, not the filesystem permissions of the user running the command. If your SSH user can write to wp-content/plugins, so can a shell.

Production wants WP_DEBUG on and WP_DEBUG_DISPLAY off

The usual advice is to set WP_DEBUG to false in production. That is reasonable, and it is also how a site runs for two years with an intermittent fatal error nobody can see. The constants are independent: WP_DEBUG enables the machinery, WP_DEBUG_DISPLAY controls whether a visitor sees a stack trace, and WP_DEBUG_LOG controls whether one is written to disk.

When WP_DEBUG_LOG is boolean true, the log lands at WP_CONTENT_DIR . '/debug.log', which is where the first section of this lesson found it. Pass a path instead, and pass one outside the document root:

define( 'WP_DEBUG', true );
define( 'WP_DEBUG_DISPLAY', false );
define( 'WP_DEBUG_LOG', '/var/log/wp/site-error.log' );

The directory has to exist and the PHP user has to be able to write it. If it cannot, PHP writes nothing and does not warn you, so verify with an actual request before trusting the setting.

WP_DEBUG_DISPLAY is the one that has to be off. With it on, any notice or warning is printed into the page, which leaks paths and, in the case of a badly written plugin echoing a variable, leaks data. WordPress already suppresses display for REST, XML-RPC and Ajax requests; the front end is the request type you have to think about.

WordPress sends four security headers, and only on some requests

Header advice for WordPress is usually a list that the author never checked against a response. Here is what a stock 7.0.7 install sends, measured rather than recalled. The front page, logged out:

HTTP/1.1 200 OK
X-Powered-By: PHP/8.5.6
Content-Type: text/html; charset=UTF-8
Link: <http://127.0.0.1:8425/index.php?rest_route=/>; rel="https://api.w.org/"

No X-Frame-Options, no X-Content-Type-Options, no Referrer-Policy, no Permissions-Policy, no HSTS. Core attaches those headers to specific hooks: send_frame_options_header() and wp_admin_headers() run on login_init and admin_init, send_nosniff_header() runs on the Ajax and upload endpoints, and the REST server sends its own.

HeaderValue core sendsOn which requests
X-Frame-Options SAMEORIGIN Login, wp-admin, admin-ajax
Content-Security-Policy frame-ancestors 'self'; Same three; the Customizer sends a wider value
Referrer-Policy strict-origin-when-cross-origin Login, wp-admin, admin-ajax
X-Content-Type-Options nosniff REST, admin-ajax, async-upload, JSON error responses
HSTS, Permissions-Policy Nothing Not core's job; your web server or host sets them

The practical reading is that the front end of every WordPress site is missing three headers people assume it has, and the admin is missing HSTS, which is the one that matters most for a session cookie. If you have ever "enabled X-Content-Type-Options" by installing a plugin, that plugin is doing what core does not, and it has to attach to a request type core skips.

HSTS is also the header with a trap. Sending Strict-Transport-Security: max-age=31536000 tells the browser to refuse plain HTTP to that host for a year. Add includeSubDomains and it applies to every subdomain, including the one that still answers on port 80 for a legacy service. Add preload and you are asking browser vendors to hard-code that rule into a shipped list, where removal takes months and cannot be undone by changing your server. Roll it out in order: HTTPS everywhere first, then a short max-age without includeSubDomains, then lengthen it, then decide about preload with a calendar reminder to review.

A Content-Security-Policy for WordPress needs a report-only phase first

The frame-ancestors line above is a CSP directive core already ships, and it is the easy half. The half that breaks sites is script-src. A default 'self' policy blocks inline scripts, and the block editor, most page builders and a large number of plugins emit inline scripts. Inline event handlers such as onclick are blocked by the same directive. You cannot enumerate the hashes by hand and keep up with plugin releases.

Start in report-only mode, which the browser logs but does not enforce, collect violations for a few days, and only then ship enforcement:

add_filter( 'wp_headers', function ( $headers ) {
    $report = rest_url( 'my-site/v1/csp-report' );
    $headers['Content-Security-Policy-Report-Only'] =
        "default-src 'self'; script-src 'self'; object-src 'none'; " .
        "frame-ancestors 'self'; report-uri {$report}";
    return $headers;
} );

Expect the report volume to need sampling, and the admin to need exceptions the front end does not. When you enforce, either allow 'unsafe-inline' in the admin only, or use nonces, which WordPress does not provide for arbitrary plugin output. A CSP that breaks the editor on a Tuesday afternoon gets disabled on Tuesday afternoon, and a disabled policy protects nothing.

Lab 25 minutes

Work against a site you control. Collect the response headers for four request types and put them in a table before you change anything: the front page, wp-login.php, wp-json/ and wp-admin/admin-ajax.php.

curl -s -D - -o /dev/null https://example.test/wp-login.php

For each row record the header name, the value, and which of the four requests sent it. Then request these three paths and record the status code and content type of each: /wp-content/debug.log, /wp-config.php and /.env.

Write down two sentences: which missing header you will add first, and which file you will move first. Then name the one change on your list that would break automatic updates if you made it today.

Show the solution and the reasoning

On a stock WordPress 7.0.7 site the four requests do not return the same headers, and that is the finding. The front page returns none of the three. wp-login.php returns X-Frame-Options: SAMEORIGIN, Content-Security-Policy: frame-ancestors 'self'; and Referrer-Policy: strict-origin-when-cross-origin. The REST route returns X-Content-Type-Options: nosniff plus X-Robots-Tag: noindex. admin-ajax.php with a valid action returns all four, because it loads the admin bootstrap and fires admin_init.

Why the split matters: any cookie-authenticated action that happens on a front-end request is outside the protection you thought the admin had. If the front end can be framed, and an attacker can construct a page that triggers a state change through a front-end route, X-Frame-Options on the admin is not covering it. The correct place for the frame-ancestors directive is a server-level header applied to the whole host, not a plugin that hooks the admin.

For the file probes, the expected results are debug.log -> 200 text/plain if logging is enabled and no rule blocks it, and wp-config.php -> 200 text/html with an empty body, because PHP executes the file and it prints nothing. That second result is the one people misread: a 200 is not a leak, it is the interpreter responding. Check the body, not the status code.

The change that would break automatic updates is either DISALLOW_FILE_MODS or making the plugin directory read-only to the web user. The order I would use: move the log outside the web root, block the extensions at the server, add the missing headers for the whole host, then plan the read-only switch as a deployment-pipeline project rather than a five-minute config change.

Official reference

The WordPress developer reference for what this lesson uses.

Progress

Mark this lesson done and it stays marked in this browser.