HeaderAudit

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.

The nonce must be genuinely random, per response, and unguessable. A nonce baked into a static file at build time is not a nonce; it is a password the attacker can read.

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

  1. Ship the policy on Content-Security-Policy-Report-Only with a reporting endpoint. Nothing is blocked; violations are reported.
  2. 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.
  3. Fix real violations by adding nonces to your own inline scripts, not by widening the policy. Every widening is permanent.
  4. Move the identical policy to the enforcing header.

Check a site