Writing a Content-Security-Policy that actually stops XSS
Of the sites scanned for this site's reference pages, a majority of those that publish a
Content-Security-Policy use an allowlist containing 'unsafe-inline' with no nonce.
Those policies stop almost nothing. Here is the difference.
Why allowlists fail
The intuitive policy is a list of trusted domains: your origin, your CDN, an analytics provider. The problem is that trust is transitive and you do not control what those hosts serve. If any allowlisted host has a JSONP endpoint — a URL that reflects a caller-supplied callback name into JavaScript — an attacker points a script tag at it and executes arbitrary code from a host your policy trusts.
Google's own research on this found the large majority of real-world allowlist policies were bypassable. Big CDNs are the usual culprit precisely because they host everything.
Rule one: a nonce disables unsafe-inline
A nonce is a random value, regenerated per response, that appears both in the header and on each script tag you intend to allow:
Content-Security-Policy: script-src 'nonce-r4nd0m'
<script nonce="r4nd0m">…</script>
An injected script tag will not carry the nonce, because the attacker cannot read a value that changes every response. So it does not run.
The subtlety worth internalising: when a nonce or hash is present, browsers
ignore 'unsafe-inline' entirely. That means you can keep
'unsafe-inline' in the policy purely to serve very old browsers, without weakening
anything in modern ones. A policy containing both is not a mistake — and a scanner that flags it
as a critical finding is producing a false positive.
Rule two: strict-dynamic discards the allowlist
Adding 'strict-dynamic' tells the browser to ignore host allowlists entirely and
instead let any script that was trusted load further scripts. That is what makes nonce-based CSP
practical — you do not have to enumerate every domain your bundler might pull from.
Content-Security-Policy: script-src 'nonce-r4nd0m' 'strict-dynamic' https: 'unsafe-inline'; object-src 'none'; base-uri 'self'
Read that policy in layers. Modern browsers see the nonce and strict-dynamic and ignore both
https: and 'unsafe-inline'. Browsers that understand nonces but not
strict-dynamic fall back to https:. Ancient browsers fall back to
'unsafe-inline'. Each layer is a graceful degradation, not a hole.
The three directives everyone forgets
default-src does not cover these, so a policy without them is missing the
protection no matter how strict default-src is:
base-uri— without it, an injected<base>tag rewrites every relative URL on the page, including relative script sources. That defeats a nonce policy, because your own approved scripts get redirected.form-action— without it, an injected or rewritten form can post credentials to any origin.frame-ancestors— the modern replacement for X-Frame-Options.
Deploying without breaking the site
- Ship the policy on
Content-Security-Policy-Report-Onlywith a reporting endpoint. Nothing is blocked; violations are reported. - Watch reports for a week or two. Expect heavy noise from browser extensions injecting script — filter by whether the blocked URI relates to your own pages.
- Fix real violations by adding nonces to your own inline scripts, not by widening the policy. Every widening is permanent.
- Move the identical policy to the enforcing header.