Your Security Headers Are Not Being Sent, and WordPress Is Why
Core emits frame, referrer and nosniff headers only on admin and login routes. Here is what each route actually sends, measured on WordPress 7.0.7.
Tomas Lindqvist · @tlindqvist
Hardening and operations
- Topic
- SEC
- Section
- Security & Hardening
- Published
- 15 OCT 2026
- Reading time
- 8 MIN
- Words
- 1835W
Revised 16 OCT 2026
A site fails a client's security questionnaire because the public pages have no
Content-Security-Policy and no Referrer-Policy. The developer who
inherited it installs a headers plugin, sets both, and tests the plugin's settings screen, where
everything looks correct. The scan still fails.
The reason is not the plugin. It is where the headers were hooked. WordPress core sends three of the four headers a typical scanner looks for, and it sends them on administration and login routes only. On the front end it sends none of them, because the code that sends them is never loaded there.
That is worth stating precisely, because the intuition most people have is the opposite. It is tempting to assume admin screens are the hardened surface and public pages inherit whatever admin has. The actual design is closer to the reverse: the public site is assumed to be the site owner's problem, and core protects its own screens.
What each route actually sends, measured
I installed WordPress 7.0.7 with the default theme, PHP 8.5.6, and a must-use plugin that logs
headers_list() during shutdown. Requests were then made with curl as an anonymous
visitor and as a logged-in administrator. The table is the union of both runs, which were identical
apart from status codes.
| Request | X-Frame-Options |
frame-ancestors |
Referrer-Policy |
nosniff |
|---|---|---|---|---|
| Front page and single post | no | no | no | no |
wp-login.php | yes | yes | yes | no |
wp-admin/ screens | yes | yes | yes | no |
wp-admin/admin-ajax.php | yes | yes | yes | yes |
wp-admin/async-upload.php | yes | yes | yes | yes |
| REST API responses | no | no | no | yes |
Two rows are worth reading twice. The first is the top one: the pages a scanner fetches have no
security headers from core at all. The second is the last one, where a REST response carries
nosniff and nothing else. The headers follow the code path, and the code paths are
disjoint.
The raw output for the front page and the login screen looks like this, trimmed to the headers under discussion:
GET / HTTP/1.1
Host: 127.0.0.1
HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8
GET /wp-login.php HTTP/1.1
Host: 127.0.0.1
HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8
X-Frame-Options: SAMEORIGIN
Content-Security-Policy: frame-ancestors 'self';
Referrer-Policy: strict-origin-when-cross-origin
An unauthenticated visitor gets those three headers on the login screen. A logged-in administrator gets the same three. Being logged in changes nothing, which is the first clue that the condition is the route rather than the user.
Three hooks and one call site explain the whole table
Core does not have a header module. It has individual functions, each wired into whatever route needs it, and reading the wiring is enough to predict the table above without running anything.
// wp-admin/includes/admin-filters.php
add_action( 'admin_init', 'wp_admin_headers' );
add_action( 'admin_init', 'send_frame_options_header', 10, 0 );
// wp-includes/default-filters.php
add_action( 'login_init', 'send_frame_options_header', 10, 0 );
add_action( 'login_init', 'wp_admin_headers' );
send_frame_options_header() emits the two framing headers.
wp_admin_headers() emits the referrer policy, and the value is filtered, so a plugin can
change it but not add it to a page where the function never runs. Both are registered against
admin_init, which fires from wp-admin/admin.php, and against
login_init, which fires from wp-login.php. A front-end request does
neither.
The fourth header has a different and narrower story. send_nosniff_header() is a
one-line function that emits X-Content-Type-Options: nosniff, and it is called from
exactly two places in the admin tree: the top of admin-ajax.php and the top of
async-upload.php. The REST API does not call it either; it sends the header through
WP_REST_Server::send_headers(), which is why a REST response has nosniff
and no referrer policy.
Verified against the source
Line numbers move between releases. The call sites above were read in the WordPress 7.0.7 core
tree and confirmed by request against the same version. On an older release the function names are
the same, but wp_admin_headers() lived in wp-admin/includes/misc.php
until 6.8.
One more property of this design is easy to miss. Because headers_list() is
inspected after WordPress has finished, the table reflects what the application decided, not what
the web server added on top. Nginx or Apache can inject headers at any point in the response, and it
usually does: a host that adds Strict-Transport-Security and a referrer policy does so
outside PHP, which is why a scanner sometimes finds a header that no WordPress code sends.
The reverse also happens, and it is the more expensive direction. A reverse proxy or a page cache can answer a request without involving PHP at all. When it does, the headers are whatever were stored with the cached entry, which means a site can serve the correct headers to one visitor and none to the next. If a scan reports headers on one run and not the next, do not go looking for a plugin conflict. Check whether an interstitial cache is in front of the application.
The plugin you installed is a hook, and hooks do not run on public pages
Most "add security headers" plugins are a handful of send_headers callbacks, and
send_headers is an action that fires during a normal front-end request. That sounds
like it solves the problem, and for plugins that hook it correctly, it does. The failure mode is
more specific than "plugins do not work".
It is that a large share of snippets in circulation hook a route the author was testing.
admin_init is the usual one, and it is where core puts the headers it does send, so it
reads as the canonical place. A snippet that adds X-Frame-Options to
admin_init passes every test an administrator performs and never touches a public page.
The same goes for login_init, and for anything hooked to
admin_notices, which is an admin-screen action by definition.
If you keep the header logic in PHP, hook send_headers, which fires on front-end
requests, and do not assume it covers REST or admin routes. If you need one policy on every route,
you have found the argument for moving the header out of PHP entirely.
The frame headers protect admin screens, which are not the threat
X-Frame-Options: SAMEORIGIN on the login page stops another site from framing your
login form. That is a real attack, and core's choice to send it there is sensible. It also means
that on the front end, where a customer's checkout page or a product page lives, nothing stops
another site from framing it. Clickjacking a public page that performs an action, such as an
add-to-cart or a logout link, is a known problem, and core's answer to it is silence.
The modern replacement for X-Frame-Options is frame-ancestors in a
Content-Security-Policy, which is what core sends alongside it. The two coexist for
older browsers. If you are choosing one for a new configuration, choose
frame-ancestors, and send X-Frame-Options as well only if you still care
about browsers that predate it.
There is one more asymmetry worth naming, because it turns up in audits. Core's
nosniff is a property of api responses, uploads and ajax calls, not of the site as a
whole, so a scanner that fetches a public HTML page reports it missing even on a site where every
other response carries it. That is not the misconfiguration a ticket usually means, but
it is a finding you will have to explain, and the explanation is the table near the top of this
article.
Add the headers at the server, in front of PHP
A header added by the server is on the response before PHP starts, applies to every route
including REST and static files, and survives a plugin conflict. It also costs nothing per request.
For an Apache vhost or .htaccess:
<IfModule mod_headers.c>
Header always set X-Content-Type-Options "nosniff"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set Content-Security-Policy "frame-ancestors 'self'"
</IfModule>
For nginx, in the server block:
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "frame-ancestors 'self'" always;
The always keyword matters in both. Without it, Apache and nginx add headers only on
successful responses, so a 403 or a 500 arrives without the protections you just configured. That is
the same class of mistake as hooking admin_init: correct on the response you tested,
absent on the response that matters.
Verify from outside the application, with the status code visible, rather than by reading the config file back:
for path in / /wp-login.php /wp-json/ /wp-admin/; do
printf '%-16s ' "$path"
curl -sI "https://example.com$path" \
| tr -d '\r' \
| grep -ciE '^(x-frame-options|content-security-policy|referrer-policy|x-content-type-options)'
done
Before the change, the first path prints 0 and the others print between two and
four. After the change, all four paths print 4. A test that only fetches the front page
and greps for a header name will pass as soon as a plugin exists, which is how the plugin approach
survives review.
Where this advice stops working
A server-level header applies to everything the vhost serves, including the WordPress admin,
where core is already sending frame-ancestors 'self'. Duplicate headers are the usual
result, and browsers handle duplicates inconsistently: two Content-Security-Policy
headers are enforced as an intersection, which is stricter than either policy alone, and that is
exactly the kind of change that breaks an embed on a page nobody tested.
The cleanest order of operations is to pick one layer per header. If the header is a property of
the site, such as Referrer-Policy or nosniff, put it at the server for all
routes. If it is specific to a page type, such as a product page that needs a different
frame-ancestors value, do it in PHP on send_headers and suppress the
server copy for that path. Doing both without a plan produces an intersection you did not design and
a support ticket you cannot reproduce.
And the limit that no configuration removes: headers only apply to responses the server actually generates. A page served from a full-page cache that was warmed before the header change will keep the old headers until the cache entry expires. Flush the cache as part of the change, not after the scan.
There is a smaller caveat on the framing policy itself. frame-ancestors is ignored
by browsers that do not implement CSP Level 2, which today means very old ones, and it is ignored
entirely if another Content-Security-Policy header on the same response already
contains a frame-ancestors directive. Two policies are combined, and the resulting
allowed set is the intersection. If you add a strict policy at the server and a plugin adds a looser
one, the browser applies the strict one and the looser one has no effect, which is a fine outcome
nobody planned and a confusing one to debug later.
What to do on Monday
- Run the loop above from outside the network and record the count per path. The front end result is the one that matters.
- Check each header for a duplicate before adding one. Two CSP headers are worse than one.
- Move the site-wide headers into the vhost with the
alwaysflag. - Leave
frame-ancestorsin core's hands on admin and login routes, and set your own for public pages. - Flush the page cache, then re-run the loop. Cached HTML carries whatever the origin sent when the entry was created.
Tomas Lindqvist · @tlindqvist
Hardening and operations
Threat models plugins, audits capability and nonce handling, and builds the observability that tells you an integration broke before a customer does.
Continue