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.
| Header | Value core sends | On 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.
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.