The security headers checklist: CSP, HSTS and the five that actually matter
Header scanners will happily grade you on twenty directives. Five of them stop real attack classes. This is what each one actually prevents, what it breaks when you get it wrong, and the rollout order that avoids taking production down.
The five that carry the weight
Header scanners grade you on twenty directives. Five of them stop real attack classes; the rest are mostly hygiene. Ship these first, in this order.
| Header | Stops | Breaks if wrong |
|---|---|---|
| Content-Security-Policy | XSS payload execution, unexpected third-party script loads | Your own scripts, fonts, analytics, embeds — the highest-risk header to ship blind |
| Strict-Transport-Security | Downgrade and cookie-stripping attacks on first hop | Any subdomain still on plain HTTP becomes unreachable — and browsers cache the rule |
| X-Content-Type-Options | MIME sniffing turning an upload into executable script | Almost nothing; safe to ship immediately |
| Referrer-Policy | Leaking full URLs (with tokens in paths) to third parties | Analytics attribution loses granularity |
| Permissions-Policy | Third-party frames silently claiming camera, mic, geolocation, payment | Legitimate embeds that need those features |
frame-ancestors replaced X-Frame-Options. If you set both, modern browsers use the CSP directive and ignore the legacy header. Keeping X-Frame-Options around costs nothing for old clients, but do not rely on it as your clickjacking defense.
CSP without taking the site down
CSP is the one that breaks production, because it fails closed: anything you forgot to allow simply stops loading. Never ship it enforcing on the first try.
Step 1 — observe with Report-Only
Content-Security-Policy-Report-Only evaluates the policy and reports violations without blocking anything. Run it in production for at least one full traffic cycle, including a marketing campaign or whatever brings in the unusual embeds.
Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self'; style-src 'self'; report-uri /csp-report
Step 2 — read the violations, then decide
Every violation is one of three things: something legitimate you forgot (add it), something legitimate you should remove anyway (remove it), or an actual injection (you just found a bug). Treat the third case as an incident, not a policy tuning task.
Step 3 — enforce, keeping report-uri
Switch the header name to Content-Security-Policy but keep reporting on. Violations after enforcement are your early warning that a deploy introduced a new dependency.
About 'unsafe-inline': with it in script-src, CSP stops being an XSS defense — an injected inline script executes exactly like your own. Nonces or hashes are the real fix. If a third-party tag forces 'unsafe-inline' on you, that is a vendor cost worth naming out loud.
HSTS: the one you cannot take back quickly
HSTS tells browsers to refuse plain HTTP for your domain, and they cache that instruction for the full max-age. Two ways this bites:
includeSubDomainscovers subdomains you forgot. A legacy internal host on HTTP becomes unreachable for every browser that has seen the header.preloadis close to irreversible. Once your domain ships inside browser binaries, removal takes months of release cycles. Add it only when every subdomain, current and planned, is HTTPS-only.
Ramp up: max-age=300 for a day, then 86400 for a week, then 63072000 once nothing has broken. Only then consider preload.
Where to set them
Set headers at exactly one layer. Two layers both setting CSP produces a merged policy that is the intersection of both — usually stricter than either author intended, and painful to debug.
# Cloudflare Pages / Netlify — one file, applies to every route /* X-Content-Type-Options: nosniff Referrer-Policy: strict-origin-when-cross-origin Strict-Transport-Security: max-age=63072000; includeSubDomains Permissions-Policy: geolocation=(), microphone=(), camera=(), payment=() Content-Security-Policy: default-src 'self'; frame-ancestors 'self'; object-src 'none'; base-uri 'self'
Verify what is actually served
A header in your config is not a header on the wire. CDNs strip, frameworks override, and edge rules silently win.
curl -sI https://example.com/ | grep -iE 'content-security|strict-transport|x-content-type|referrer-policy|permissions-policy' # Check a deep route too — some setups only cover the root curl -sI https://example.com/some/deep/path | grep -i content-security
Then open the site in a real browser and read the console. A DOM check will not show you a blocked font or a stripped script; a CSP violation message will.
Generating headers from policy
TJ Sentinel emits this set as part of the browser boundary, in the format your stack consumes — _headers for static hosting, middleware for Express and Next.js, or a Worker response transform. The frame-ancestors and third-party allowances come from the policy file, so the same intent produces the same header wherever the site runs.