SiteHealthRadar

Guides

Missing security headers: how do you fix them?

By Md Jewele Islam. 1 November 2026. Updated 3 November 2026.

To fix missing security headers, add them where your site's responses are produced: the nginx or Apache config, your CDN's header rules, or a WordPress plugin if you can't reach the server. Add X-Content-Type-Options and framing protection first, then HSTS with a short lifetime, and put Content-Security-Policy in report-only mode until you know it won't break pages. A missing header is a weakness, not a sign you've been hacked.

What does each header do?

HeaderWhat it doesWhat could go wrong
X-Content-Type-Options: nosniffStops the browser guessing a file's type from its contentsScripts or styles served with the wrong type get blocked
X-Frame-OptionsControls who can show your page in a frameBreaks a legitimate embed on another site
Strict-Transport-Security (HSTS)Tells browsers to use only HTTPS for your siteCertificate errors can't be clicked past while it's active
Content-Security-Policy (CSP)Limits what the page may load, which helps guard against cross-site scriptingA strict policy can stop scripts, styles or images from loading

None of these stops someone logging in with a stolen password or exploiting an old plugin. They close specific browser-side gaps, and that's all.

How do you see which headers your site sends now?

curl -sSI https://sitehealthradar.com/ \
  | grep -iE '^(strict-transport-security|content-security-policy|x-frame-options|x-content-type-options)'

Real output, run on 1 November 2026 against this site's own domain:

content-security-policy: default-src 'self'; script-src 'self'; style-src 'self'; font-src 'self'; img-src 'self' data:; connect-src 'self'; form-action 'self'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'
x-content-type-options: nosniff
x-frame-options: DENY
strict-transport-security: max-age=31536000

The same command against example.com that day printed nothing, because that page's response had none of these four. An empty result is what "missing" looks like.

Headers are per response, so check more than your homepage. A login page, a checkout or an API path can behave differently.

What lines do you add on nginx?

Put these in the server block for your HTTPS site, using add_header:

add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Strict-Transport-Security "max-age=300" always;
add_header Content-Security-Policy-Report-Only "default-src 'self'; frame-ancestors 'self'" always;

Then sudo nginx -t, and if that passes, sudo systemctl reload nginx.

These four lines were tested on 1 November 2026 on a throwaway local nginx 1.31.8 over HTTPS with a test certificate. nginx -t passed, and all four headers came back on a 200 and on a 404. nginx -s reload was used there, because that machine has no systemctl. A local server can't show how your own pages react, so try a staging site first.

Two rules from the docs catch people out. Both behaved as documented in that test:

  • Use always. Without it, nginx adds the header only for certain status codes, such as 200 and 301, and not for error pages. In the test, a line without always was there on the 200 and gone on the 404.
  • A location or server block with its own add_header loses the parent's headers. Directives are inherited only if the current level has none. So a line in http vanishes in any server block that sets a header of its own. In the test, adding Cache-Control in a location removed all four security headers from that path. Either repeat them there, or on nginx 1.29.3 and later add add_header_inherit merge;, which brought them back.

What lines do you add on Apache?

Using mod_headers, which has to be enabled:

Header always set X-Content-Type-Options "nosniff"
Header always set X-Frame-Options "SAMEORIGIN"
Header always set Strict-Transport-Security "max-age=300"
Header always set Content-Security-Policy-Report-Only "default-src 'self'; frame-ancestors 'self'"

These exact four lines were tested on 1 November 2026 on a throwaway local Apache 2.4.67 over HTTPS with a test certificate. httpd -t returned Syntax OK, and all four headers came back on a 200 and on a 404. With mod_headers not loaded, the config test failed with Invalid command 'Header', so that's the error to look for. A local server can't show how a real browser or your own site reacts.

What if you're on WordPress or shared hosting?

You may not have access to the server config. In that case:

  • .htaccess. Apache allows Header there only if the host's AllowOverride includes FileInfo. On the same test server, the four lines above worked on a 200 and a 404 with AllowOverride FileInfo. With AllowOverride None, they were silently ignored, with no error at all. So check with curl afterwards, or ask your host. And if your host runs nginx, .htaccess isn't read.
  • Your CDN. Check whether it has response header rules. On Cloudflare they're called Response Header Transform Rules, and they weren't tested for this guide. For other CDNs, read their docs.
  • A plugin. Use a security or headers plugin you trust. A plugin that sends headers from PHP can only touch responses WordPress generates, and WordPress's standard .htaccess rules let the web server hand out existing files, such as images and CSS, without running WordPress at all. That's our reasoning from those rules, and no plugin was tested. Some may write to .htaccess instead. So check an image or a CSS file as well as a page.

Whichever you pick, set each header in one place. On a test nginx proxying another server, with both setting X-Frame-Options, the response carried both lines: DENY and SAMEORIGIN. Browsers don't treat every duplicate the same way. For HSTS, the first one counts. For CSP, all the policies apply. Either way you'll be debugging the wrong layer. On Cloudflare, a rule that "sets" a header overwrites it, while one that "adds" keeps the existing one.

How do you turn on HSTS safely?

Be careful with this one, because browsers remember it.

  1. Make sure HTTPS works on the whole site, and that certificate renewal is working.
  2. Start with max-age=300, which is five minutes.
  3. Raise it in stages, as the HSTS preload site suggests: max-age=604800 (a week), then max-age=2592000 (a month). It says to check for broken pages at each stage and wait the full max-age before moving on. After that, max-age=31536000 is a year.
  4. Add includeSubDomains only when every subdomain works on HTTPS. An old mail. or shop. on plain HTTP will stop working for anyone whose browser has the rule. That preload site's examples include it from the start, because it's aimed at preloading, and it tells you to check every subdomain first.
  5. Leave preload off unless you're sure. The preload list warns that getting removed is slow, and that it takes months to reach users.

Browsers ignore HSTS sent over plain HTTP, so testing it on an HTTP-only staging site proves nothing. To back out, send max-age=0, which tells a browser to forget the rule. That only reaches browsers that can still connect to you over HTTPS.

The tests above show the server sends the header. How a browser behaves once it has the rule comes from those docs and wasn't tested here.

How do you add Content-Security-Policy without breaking the site?

Start with Content-Security-Policy-Report-Only. The browser notes what the policy would have blocked but doesn't block it.

  1. Add the report-only line above. default-src 'self' is a strict starting point, and a site with analytics, chat widgets, web fonts or inline scripts will show plenty of violations. That's expected.
  2. Open your main pages with the browser's developer console open, and read what would be blocked. In a test on Chromium 156, a page with an inline script under that exact header ran the script and logged the violation in the console, as an info message rather than an error. So don't filter info messages out. Other browsers weren't tested.
  3. If you want reports sent somewhere, add a report-to endpoint. MDN goes further and says the header has no effect without report-to, which doesn't match what the console did in that test. Our reading is that it means no reports get sent. If you need a record, set one up.
  4. Add the sources you trust. Avoid 'unsafe-inline' as a shortcut. MDN warns that it "defeats much of the purpose of having a CSP". It suggests nonces or hashes for inline scripts instead.
  5. When the important pages come up clean, rename the header to Content-Security-Policy, then test again, including checkout, forms and logins.

Report-only needs to be sent as a real header. MDN says it isn't supported in a <meta> tag.

A scanner may still call CSP missing while you're in report-only mode. Ours does, and that's fair, because nothing is being enforced yet.

X-Frame-Options or frame-ancestors?

Either one protects you from being framed. MDN points to frame-ancestors for more comprehensive options. X-Frame-Options takes DENY (nobody) or SAMEORIGIN (only your own site). ALLOW-FROM is obsolete, and modern browsers ignore the whole header if they see it. If another site needs to embed you, use frame-ancestors and list that site, and drop X-Frame-Options, since it has no way to name another site.

How do you know it worked?

Run the curl command again, on your homepage and on one or two inner pages. Then click through the parts of the site that make you money. A header that's present and a working checkout are two separate tests. X-Content-Type-Options can bite here: in the same Chromium test, a script served as text/plain was refused once nosniff was on.

If you're behind a CDN, remember what you're seeing is what the CDN sends. Clear its cache if an old response comes back.

If you'd rather hand this over, it's the sort of thing we fix on request, quoted per job after a look.

Which headers is your site missing?

The free security headers checker reads one ordinary response from your homepage and looks for HSTS, X-Content-Type-Options, framing protection and Content-Security-Policy, plus whether the response announces a version number. Each missing one comes with a suggested fix. It looks at the homepage only, and it counts only an enforcing CSP, so report-only still shows as missing. Its HSTS suggestion includes includeSubDomains, so read the step above before you copy that part.

Sources were read on 1 November 2026 and checked again on 3 November 2026. Vendor pages change, so check them if you're reading this much later.