How to Test Security Headers Before You Ship
Security headers are unusually unforgiving to deploy. A Content Security Policy that is slightly too strict does not degrade, it blanks your page. Setting the header locally first turns a production incident into a five-minute experiment against the real site, with the real third-party scripts, in your own browser.
Content Security Policy
Start by setting the policy you intend to ship as a response header on your staging domain, then click through the app with the DevTools console open. Every violation is logged with the directive that blocked it, which is a much better source of truth than guessing which analytics vendor needs which host.
Match: domain is staging.example.com
Action: set response header Content-Security-Policy:
default-src 'self';
script-src 'self' https://plausible.io;
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
connect-src 'self' https://api.example.com;
frame-ancestors 'none';
base-uri 'self'
- Test in report-only mode first: set
Content-Security-Policy-Report-Onlyinstead. Violations are reported and nothing is blocked, which is the right first step on a site you cannot afford to break. - Expect inline styles to be the hard part: many frameworks inject inline styles.
unsafe-inlinefor styles is a common, pragmatic compromise.unsafe-inlinefor scripts is not. - Watch for a meta tag CSP: a policy delivered in HTML combines with the header, and the result is the intersection of both. If your header seems ignored, check the document.
- Remember multiple policies stack: two CSP headers do not override each other. A resource must satisfy every policy present, which is why appending a second CSP only ever makes things stricter.
Framing: X-Frame-Options and frame-ancestors
To check whether your app can be embedded, set the header locally and load your page in an iframe on a scratch page. To check the opposite, that a partner can embed you, remove the header locally and confirm the embed works before you weaken anything on the server.
# Cannot be framed at all
set response header X-Frame-Options: DENY
# Only a named partner may frame it (CSP is the modern control)
set response header Content-Security-Policy: frame-ancestors https://partner.example.com
X-Frame-Options is the legacy header and frame-ancestors supersedes it. Browsers that support both prefer the CSP directive, so when they disagree, the CSP wins in modern browsers and the legacy header still matters for old ones.
HSTS: the one to be careful with
Strict-Transport-Security is not a header you should casually inject, because the browser remembers it. Setting a long max-age for a host pins that host to HTTPS in your profile for the duration, whether or not your rule is still enabled, and any plain-http development on that hostname stops working.
- Test with a short max-age, such as 300 seconds, not the year you plan to ship.
- Never test
includeSubDomainsagainst a hostname whose subdomains you use over http locally. - To undo it, send
Strict-Transport-Security: max-age=0for that host, or clear the entry fromchrome://net-internals/#hsts. - Preloading is a separate, slow-to-reverse commitment. Nothing about it can be sensibly tested with a local header.
The rest of the set
| Header | Try | What to watch for |
|---|---|---|
X-Content-Type-Options | nosniff | breaks resources served with the wrong content type, which is the point |
Referrer-Policy | strict-origin-when-cross-origin | attribution and analytics that depend on a full referrer path |
Permissions-Policy | camera=(), microphone=(), geolocation=() | any feature your app genuinely uses will start failing |
Cross-Origin-Opener-Policy | same-origin | OAuth popups and payment windows that need a handle on the opener |
Cross-Origin-Embedder-Policy | require-corp | every cross-origin resource now needs CORP headers. Test before shipping, always |
Cache-Control | no-store on authenticated pages | confirm nothing sensitive is served from the back-forward cache |
A workable sequence
- Set the header locally against staging and use the app normally for a few minutes.
- Read every console violation and decide, one by one, whether to allow the resource or remove it.
- Deploy in report-only mode where the header supports it, and watch real traffic.
- Enforce, starting with a short max-age or a narrow scope where the header has one.
- Verify from outside your browser with
curl -I, so you are reading the server and not your own rule.
Turn these rules off when you finish. A forgotten local CSP that blocks a script makes a site look broken for you and nobody else, and that is a genuinely painful hour of debugging.
Common questions
Can I test a CSP without deploying it?
Yes. Setting Content-Security-Policy as a response header in your browser applies the real policy to the real site, so you see exactly which resources it blocks. It is the closest thing to a dry run, and using Content-Security-Policy-Report-Only makes it non-blocking.
Why did my site stop loading over http after I tested HSTS?
Because the browser cached the Strict-Transport-Security policy for that host and now upgrades every request. Send max-age=0 for the host, or delete the entry in chrome://net-internals/#hsts. Testing with a short max-age avoids the problem.
Do local header rules test the header the way a real deployment would?
For everything the browser enforces, yes: CSP, framing, referrer policy, and permissions all behave as they would from the server. What local rules cannot test is anything outside the browser, such as a CDN rewriting headers or a scanner grading your domain.
Should I remove security headers to make development easier?
Only for a specific test, and only briefly. Removing a CSP locally to confirm it is the cause of a problem is reasonable. Leaving it removed means you are developing against a configuration none of your users have.