Content-Security-Policy is the most powerful header a browser gives you and the one most often deployed in a form that provides almost no protection.
The reason is that “has a CSP” is binary in a questionnaire and continuous in reality. Here is the ladder, what each level stops, and where the drop-off happens.
Level 0: no CSP
No policy. Any injected script executes.
More common than you would expect on authenticated application subdomains, because the marketing site got the security review and app. did not.
Level 1: a CSP containing unsafe-inline
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'
This is the level most deployments reach and stay at. It passes a header-presence check and it stops essentially nothing, because unsafe-inline permits any inline script, which is exactly what an injection produces.
If your policy contains unsafe-inline in script-src, you are at level 1 regardless of how long the rest of the header is. Treat the presence of that token as the single most important thing to find and remove.
Level 2: an allowlist without unsafe-inline
Content-Security-Policy: script-src 'self' https://cdn.example.com https://analytics.example.com
Real protection against trivially injected inline script. Two structural problems.
The allowlist grows. Every new vendor adds a domain, and nobody removes one, so it drifts toward permitting most of the internet.
Allowlists are bypassable through their own entries. A permitted CDN that hosts arbitrary user content, or an allowed domain serving a JSONP endpoint or an outdated framework with a known gadget, turns into an execution path. This is well documented and it is why allowlist policies are no longer the recommendation.
Level 3: nonce plus strict-dynamic
Content-Security-Policy:
script-src 'nonce-{random}' 'strict-dynamic' https: 'unsafe-inline';
object-src 'none'; base-uri 'self'
The current recommended shape. Each response carries a fresh nonce; only scripts bearing it execute. strict-dynamic lets those trusted scripts load their own dependencies without maintaining a domain list, which is what makes the policy stop growing.
The trailing https: and 'unsafe-inline' are deliberate fallbacks for older browsers that do not understand strict-dynamic. Browsers that do understand it ignore both. This looks alarming in a review and is correct.
The supporting directives matter at this level:
object-src 'none'removes plugin execution paths.base-uri 'self'prevents a<base>injection from hijacking every relative URL on the page.frame-ancestors 'none'handles clickjacking with more precision than X-Frame-Options.
Getting to level 3 requires a nonce in your templating layer, which is genuine work on an older application. It is the highest-value step on this ladder.
Level 4: Trusted Types
require-trusted-types-for 'script';
trusted-types sanitizer default;
Levels 1 through 3 control where scripts come from. None of them address DOM XSS, where no script is loaded at all: a string the application already trusts is passed to innerHTML or an equivalent sink and becomes code.
Trusted Types makes those sinks refuse strings. The only way to write to them is through a named policy you declared, which turns “are all four hundred assignments safe” into “are these three policies correct”.
Both directives matter. require-trusted-types-for turns on enforcement; trusted-types fixes the set of policies that may be created, so a compromised third-party script cannot mint its own. Enforcement without the allowlist is a partial deployment.
The full deployment path, including report-only.
Level 5: reporting, and the integrity layer beside it
A policy with no reporting is a policy you cannot maintain, because violations are silent and you learn about breakage from users.
report-to csp-endpoint;
Reporting-Endpoints: csp-endpoint="https://example.com/_reports/csp"
Use report-to with a Reporting-Endpoints header. report-uri is deprecated, though still widely honoured, so sending both during transition is reasonable.
At this level it is also worth naming what CSP structurally cannot do: it does not protect against server-side vulnerabilities, and it does not protect against a supply-chain compromise of a script source you already permit. The script arrives from an allowed origin with a valid nonce, and CSP has no opinion about its contents. That gap is covered by Subresource Integrity and its enforcement header, not by any CSP directive.
The ladder at a glance
| Level | Shape | Stops |
|---|---|---|
| 0 | No header | Nothing |
| 1 | unsafe-inline present | Effectively nothing |
| 2 | Allowlist | Naive injection; bypassable via allowed origins |
| 3 | Nonce + strict-dynamic | Injected script execution |
| 4 | + Trusted Types | DOM XSS at the sink |
| 5 | + reporting, + SRI enforcement | Drift and third-party tampering |
How to find your level in one command
curl -sI https://yourdomain.com | grep -i content-security-policy
Then, in order: is unsafe-inline in script-src? Is there a nonce? Is strict-dynamic present? Is require-trusted-types-for present? Is there a reporting endpoint?
Check your application subdomain separately from your marketing site. They are frequently at different levels, and the one holding customer data is often the lower of the two.
The short version
Header presence is not a control. unsafe-inline in script-src puts you at level 1 no matter what else the policy says.
Level 3 is the highest-value step. Level 4 addresses an entire attack class the first three do not touch.
Ready to put this into practice?
Two ways to start — pick what fits. Free Scan if you want to see your security grade in 60s with no commitment. Free 14-day Growth trial if you're ready to monitor multiple domains, export NIS2 reports, and download Deal Reports — no credit card required.
No credit card · Cancel anytime · GDPR-ready · EU-hosted