Skip to content
System nominalUTC --:--:--Articles 071

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

Technical cover graphic for Your Security Headers Are Not Being Sent, and WordPress Is Why
Generated schematic — SEC series, REV 2.6.

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 postnononono
wp-login.phpyesyesyesno
wp-admin/ screensyesyesyesno
wp-admin/admin-ajax.phpyesyesyesyes
wp-admin/async-upload.phpyesyesyesyes
REST API responsesnononoyes

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

  1. Run the loop above from outside the network and record the count per path. The front end result is the one that matters.
  2. Check each header for a duplicate before adding one. Two CSP headers are worse than one.
  3. Move the site-wide headers into the vhost with the always flag.
  4. Leave frame-ancestors in core's hands on admin and login routes, and set your own for public pages.
  5. 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

Related in Security & Hardening