Every security-header checklist I have read spends most of its length on Content Security Policy and then lists six more headers with a one-line description and a recommended value.
That is how sites end up with a header block nobody can explain. Somebody pasted the recommended values, the scanner turned green, and now Permissions-Policy denies a camera the application never asked for while Referrer-Policy quietly ships full URLs to an analytics vendor.
CSP deserves the attention it gets, and testing it properly is its own job. This post is about the rest of the block: what each header stops, what it does not, and how to check that the browser agrees with you.
Start from the response, not the config
The only version of your headers that matters is the one a browser receives:
curl -sS -D - -o /dev/null https://example.com | grep -iE '^(strict-transport|referrer|permissions|cross-origin|x-content-type|x-frame)'
Run it against more than the homepage. A framework middleware, a CDN transform rule, and a hand-written rule on one route can each add or drop headers, and the combination is rarely what any one of them intended. API responses and file downloads are the usual places where the block goes missing.
Strict-Transport-Security
HSTS tells the browser to refuse plaintext HTTP for this host for a period of time. It closes the gap between a user typing example.com and your redirect answering: after the first successful visit, the browser upgrades the request itself and never sends the first insecure one.
Strict-Transport-Security: max-age=31536000; includeSubDomains
Two things worth understanding before you copy that line.
includeSubDomains applies the rule to every subdomain, including ones you forgot about. If an internal tool sits at legacy.example.com on plain HTTP, it stops loading for anyone who has visited the apex. That is usually the correct outcome, but it should be a decision rather than a surprise on a Friday.
preload is the one directive that is genuinely hard to undo. Submitting to the HSTS preload list ships your domain inside browser binaries, and removal takes months to reach users. Add it when every subdomain is permanently on HTTPS, not while you are still migrating.
A short max-age is a reasonable way to start. Raise it once you are confident, because a low value is close to no protection at all.
Referrer-Policy
This is the header most likely to be leaking something right now.
Without a policy, browsers default to strict-origin-when-cross-origin, which is a sane default. The problem is sites that set something looser on purpose, usually because a marketing tool asked for it, or because a copied config predates the default changing.
The leak is specific: your URLs. A password reset link, a shared document URL, a search page with the query in the path, an admin route with a record ID. Anything in the URL travels in the Referer header to every cross-origin resource the page loads.
Referrer-Policy: strict-origin-when-cross-origin
Use no-referrer when even the origin is sensitive. Avoid unsafe-url. If an analytics or attribution tool needs a full referrer, that is a conversation about what your URLs contain, not a reason to broaden the header globally.
Verify it by loading a page with a distinctive path and watching the request headers of a cross-origin asset in DevTools. The header value tells you the intent. The Network panel tells you the result.
Permissions-Policy
Permissions-Policy controls which browser features the page and its frames may use: camera, microphone, geolocation, payment, and a long tail of others.
The common failure is treating it as a security control against your own code. It is not. Your own JavaScript can already ask for the camera, and the user prompt is the real gate. The header earns its place in two situations:
- You embed third-party frames and want to stop them from asking for capabilities you never intended to delegate.
- You want a deliberate deny list so that a future dependency or injected script cannot quietly request a sensor.
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()
Deny what you do not use, and allow what you do with the narrowest origin list. A block that denies twenty features you have never heard of is not stronger; it is just longer. If you embed a payment provider in an iframe, payment=(self "https://checkout.example.com") is the shape you want.
Test it by calling the API from the console on a real page and confirming the promise rejects with a permissions error rather than showing a prompt.
X-Content-Type-Options and X-Frame-Options
X-Content-Type-Options: nosniff is one of the few headers where the recommended value is the only sensible value. It stops the browser from second-guessing your Content-Type, which is how a user-uploaded text file becomes an executing script. Set it everywhere, including on API and asset responses.
X-Frame-Options is the older sibling of the CSP frame-ancestors directive. Modern browsers honor frame-ancestors and it is strictly more capable, so frame-ancestors is the one to get right. Keeping X-Frame-Options: DENY alongside it costs nothing and still helps with older clients, but if the two disagree you now have a bug that only appears in some browsers. Make them say the same thing.
Framing protection matters more than it looks. It is the difference between a clickjacking write-up and a real one, and it applies to authenticated routes, not the marketing pages people usually test.
The cross-origin isolation headers
Cross-Origin-Opener-Policy, Cross-Origin-Embedder-Policy, and Cross-Origin-Resource-Policy are where most checklists lose people, because they are described in terms of SharedArrayBuffer and high-resolution timers rather than anything a normal application does.
Here is the practical version.
Cross-Origin-Opener-Policy: same-origin severs the window.opener relationship with cross-origin pages. That means a page you open, or one that opened you, cannot reach into your window object. This is cheap and worth setting on authenticated pages. Watch out for OAuth and payment flows that use popups and expect to talk back to the opener; those need same-origin-allow-popups.
Cross-Origin-Resource-Policy: same-site on your own assets stops other origins from embedding them. Useful for images and JSON you do not want hotlinked or read cross-origin.
Cross-Origin-Embedder-Policy: require-corp is the expensive one. It requires every cross-origin resource you load to explicitly opt in, and it breaks third-party embeds until each vendor cooperates. Turn it on when you actually need cross-origin isolation, not because a scanner suggested it.
The MDN cross-origin isolation guide explains what the isolation buys you. If you cannot name the capability you are unlocking, COOP alone is the sensible stopping point.
Headers that are cargo cult
Some values in circulation do nothing, or worse.
X-XSS-Protection is dead. The filter it controlled was removed from Chrome and Edge because it introduced its own vulnerabilities, and the only value that was ever safe was 0. Setting 1; mode=block in 2026 is a decorative string.
X-Powered-By, Server version strings, and framework banners are not a security control in either direction. Removing them is tidy. It is not defense, and a checklist that scores it next to HSTS is miscalibrated.
Expect-CT is obsolete. Certificate Transparency enforcement is now the browser’s default behavior.
Feature-Policy was renamed to Permissions-Policy and uses different syntax. Sending both is a sign of a copied config, not backward compatibility.
What to check on a real site
Walk the routes where a missing header actually costs something:
- The signed-in application shell, not the marketing homepage
- Password reset and email confirmation links, where the URL is the secret
- Any route that renders user-uploaded content
- API responses, which frequently skip the shared middleware
- Static assets served from a different origin or a CDN rule
- Error pages, which are often generated somewhere else entirely
For each one, confirm the header is present, then confirm the browser behaves the way the header claims. The header is the intent. The behavior is the proof, and the two disagree more often than you would expect.
SiteCMD’s live-site checks flag missing or contradictory headers across the routes it crawls, which is the part worth automating. The parts above that require judgment, like whether includeSubDomains will break an internal tool, stay a decision you make once and write down. Put the outcome in your pre-launch checklist so the next deploy does not quietly drop the block.