HTTP security headers: what each one does and what the web actually sets
A scanner report or a customer’s security team has handed you a list: X-Content-Type-Options missing, X-Frame-Options missing, no Content-Security-Policy, no Strict-Transport-Security, Server header discloses version. You want to know which of those are real, and whether the auditor would flag most of the web with the same list. The table answers both. Each section after it gives you the line to add and the console error you’ll see if it misfires, and one covers the two headers to remove instead.
The adoption column shows how common a finding is, and the verdict column says whether to act on it. Read both, because a low share in the middle column is no reason to skip a header; the ones almost nobody sets are the ones that do the most work.
Find your finding in the table
| Header | What it stops | Set, of the domains where the census checked | Verdict |
|---|---|---|---|
| X-Content-Type-Options | A browser guessing that an uploaded file is a script | 22,381,337 (17.8%) | Set it today; it’s one line and only affects resources that are already mislabelled. |
| X-Frame-Options / frame-ancestors | Your page loaded invisibly inside someone else’s, with a button under the visitor’s cursor | 19,226,529 (15.3%) | Set it today unless you sell an embeddable widget. |
| Strict-Transport-Security | A returning visitor’s first request going over plain HTTP | 44,684,372 (21.1%) | Set it, after the certificate is solid. |
| Content-Security-Policy | Injected script running as if it were yours | 12,734,849 (10.1%) rated effective | Ship the three-directive minimum now; a full script-src is a project. |
| Referrer-Policy | Your URLs, tokens included, leaking to every site you link to | 9,952,036 (7.9%) | Set it; it takes ten seconds. |
| Permissions-Policy | An embedded frame asking for camera, microphone or location | not reported by the census | Set it if you embed anything; otherwise low priority. |
| X-XSS-Protection | No attack in any current browser | not reported by the census | Remove it; any tool that flags its absence is running a stale rule set. |
| Server / X-Powered-By | No attack; each announces a version | not quoted here | Strip the version, and if the CDN owns the header, record it as accepted. |
Two denominators sit behind that column. The census checked Strict-Transport-Security on the 212,154,494 domains that answered on HTTPS, which is the only place the header means anything, and it checked the four page headers on 125,815,036 domains. So when the report says your site lacks X-Content-Type-Options, the auditor is describing 103,433,699 (82.2%) of the sites the census checked, and each of them could fix it with the line below.
X-Content-Type-Options
Browsers used to inspect the first bytes of a response and override the declared Content-Type if the bytes looked like something else. A file uploaded as a profile picture, served as image/png, could be run as JavaScript if it started with the right bytes and a page referenced it in a script tag. nosniff tells the browser to believe the Content-Type header and refuse anything mislabelled.
X-Content-Type-Options: nosniff
The only thing it can break is a resource you are already serving with the wrong type. When that happens the console tells you exactly which one: Refused to execute script from 'https://www.example.com/app.js' because its MIME type ('text/plain') is not executable, and strict MIME type checking is enabled. That’s a bug you want to find in staging, before a browser papers over it in production. Fix the type on the server and the header stays.
On nginx: add_header X-Content-Type-Options "nosniff" always;. On Apache: Header always set X-Content-Type-Options "nosniff". If this is the only item on the audit list, the auditor was thorough and you are in good shape.
Stopping clickjacking with X-Frame-Options and frame-ancestors
Clickjacking works by loading your page inside an invisible iframe on a page the attacker controls, then lining up your “Confirm transfer” or “Delete account” button under a button of their own. The visitor’s click goes through to your button, which they never saw. The defence is telling browsers who may frame you.
X-Frame-Options: SAMEORIGIN
Content-Security-Policy: frame-ancestors 'self'
X-Frame-Options came first and takes DENY or SAMEORIGIN. The ALLOW-FROM value never worked in most browsers and is gone; if a tool suggests it, ignore that line of the report. The CSP directive frame-ancestors replaces it and takes a list, so a checkout that must run inside one partner’s frame becomes frame-ancestors 'self' https://checkout.partner.example. A browser that understands both obeys frame-ancestors and ignores X-Frame-Options, and an older one does the reverse, so send both.
When it blocks something, Chrome says so in the console: Refused to display 'https://www.example.com/account' in a frame because it set 'X-Frame-Options' to 'sameorigin'. If you see that on your own site, something of yours is being framed from another origin, and you should know what.
The one good reason to leave framing open is that framing is your product, an embeddable calendar or a payment form. Set frame-ancestors to the origins that may embed it, on that path alone, and keep everything else at 'self'.
Why Strict-Transport-Security waits for the certificate
HSTS tells a browser that has visited you once over HTTPS to refuse plain HTTP to your name for max-age seconds, even if the visitor types http:// or follows an old link. It closes the window on the first request of each visit, which is the request a network attacker can intercept.
Strict-Transport-Security: max-age=31536000; includeSubDomains
Two things trip audits here. Browsers ignore the header on an HTTP response, so a report that says “HSTS missing” after fetching http:// is testing the wrong response; the header belongs on the HTTPS answer and on the redirect to it. And HSTS turns a certificate warning a visitor could click past into one they cannot, so a lapsed certificate under HSTS locks every returning visitor out until you renew. Get the certificate renewing on a timer first. /tls walks through the warning codes and the renewal check.
Of the 212,154,494 domains that answered on HTTPS in the September census, 44,684,372 (21.1%) sent this header. Once yours does, the next step is preload, which bakes your domain into the browsers themselves, and that step is hard to undo. /hsts covers the rollout order, including how long to run a short max-age before committing.
Content-Security-Policy in two stages
CSP is an allowlist. It tells the browser where scripts, styles, images and frames may load from, and it refuses anything outside the list. A working policy means an attacker who manages to get a script tag into your page still can’t run it. The trouble is that every inline <script> block and every onclick= attribute also violates script-src 'self', so a careless policy takes the site down along with the attacker.
The console message when that happens is long and precise: Refused to load the script 'https://cdn.example.net/widget.js' because it violates the following Content Security Policy directive: "script-src 'self'". Note that 'script-src-elem' was not explicitly set, so 'script-src' is used as a fallback. Every violation is logged like that, which is what makes CSP safe to test.
Start with the report-only form. It logs each violation and lets the page load as normal:
Content-Security-Policy-Report-Only: default-src 'self'; report-to csp-endpoint
Run that for a week in production, read the reports, and you have an inventory of everything your pages load. Write the enforcing header from the inventory. The old escape hatch, 'unsafe-inline', satisfies a tool that only checks for a header’s presence and provides none of the protection; a tool that reads the policy will flag it, and that finding is correct. The fix for inline script is a per-response nonce, which is a change in the build pipeline, not a line in the server config.
If the report says only “no Content-Security-Policy”, ship the minimum that leaves your existing pages alone and still closes three real attacks:
Content-Security-Policy: frame-ancestors 'self'; base-uri 'self'; form-action 'self'
frame-ancestors is the clickjacking control above. base-uri stops an injected <base> tag redirecting every relative URL on the page. form-action stops a form being pointed at another origin. None of the three touches scripts, so what you load today keeps working. Of the 125,815,036 domains where the census checked, 12,734,849 (10.1%) carried a policy the census rated effective, so a finding of “no CSP” is common. It is also a finding worth clearing, in two stages.
Referrer-Policy and Permissions-Policy
When a visitor follows a link from your page to another site, the browser sends the address they came from in a Referer header. If that address held a password reset token or a session id in the query string, the other site now has it in its access log. Current browsers trim this for cross-origin requests by default; the header pins the behaviour down so it no longer depends on the browser’s default.
Referrer-Policy: strict-origin-when-cross-origin
That value sends your full URL to your own pages and the bare origin to everyone else. Of the domains the census checked, 9,952,036 (7.9%) sent any Referrer-Policy at all. Once it’s in, it needs no maintenance.
Permissions-Policy decides which browser features a page and its frames may request. Permissions-Policy: camera=(), microphone=(), geolocation=() means no code on the page, including code inside a third-party iframe, can prompt the visitor for any of the three. If you embed anything, it is a one-line guarantee that the embed cannot ask on your behalf.
Two headers to remove
X-XSS-Protection controlled a filter that Chrome removed in 2019 and that Firefox never shipped. Current browsers ignore the header, and in some older versions the filter it enabled could itself be turned against a page. If a report lists its absence as a finding, the tool behind the report has not updated that rule in years. If you need the line item closed, X-XSS-Protection: 0 is the value that tells the remaining old browsers to leave the filter off.
Server: nginx/1.24.0 and X-Powered-By: PHP/8.2.12 give an attacker the version to look up and give you no benefit in return. On nginx, server_tokens off; trims Server to the bare product name, and on Apache ServerTokens Prod does the same. X-Powered-By comes from the application runtime: expose_php = Off in php.ini, app.disable('x-powered-by') in Express, or Header unset X-Powered-By at the web server as a backstop.
A CDN sometimes writes its own Server header after yours, and you can’t remove that one. Record it as accepted, with the reason, and move on, since the audit only needs a decision on that line.
Set them in one block and read them back
Put the whole set in one include file and reference it from every server block. On nginx the file looks like this, and the always flag matters because without it the headers are absent from 404 and 500 responses, which is exactly where a scanner looks:
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Content-Security-Policy "frame-ancestors 'self'; base-uri 'self'; form-action 'self'" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
The nginx trap is inheritance. A location block that contains any add_header of its own inherits none from the server block, so a single add_header Cache-Control inside location /static/ drops every security header from every static file. Include the file again inside any location that adds a header of its own. On Apache the equivalent is a set of Header always set lines in the virtual host, and they inherit without the trap.
Then read them back from outside, following the redirect so you test the response a visitor receives:
curl -sIL https://www.example.com/ | grep -iE "^(strict-transport|content-security|x-frame|x-content-type|referrer-policy|permissions-policy)"
Six lines back means the block is live on that path. Repeat against a page under any location with its own headers, and against a URL that returns 404, because those are the responses that fail the re-test. Of the 111,303,315 domains where the September census ran its all-headers check, 1,384,815 (1.2%) passed every header at once.
The scan reads the same response a browser gets and reports each header by name, with its value, so you can compare it line for line against the audit finding. If the list arrived stapled to a customer’s supplier questionnaire, the same document usually has an email authentication section next to the web one, and /supplier-questionnaire-email-authentication covers how to answer that half. Paste the block, reload, run the scan again, and attach the second result to your reply.
Figures as of 5 September 2026, from the September 2026 edition of the defaults.exposed census. Census numbers move every month; the current values are on the census data page.