Content Security Policy, properly

A practical guide to strict Content Security Policy: nonces, hashes, strict-dynamic, Trusted Types, reporting, and the deployment path that avoids breaking production.

Content Security Policy, properly
Created by ChatGPT

Content Security Policy (CSP) is one of those web platform features that everyone has heard of, most teams have a half-broken version of in production, and very few actually understand. The original 2014-era advice — "allowlist the domains your scripts come from" — turned out to be mostly theatre. Researchers found that the vast majority of allowlist-based CSPs could be bypassed in practice, often by abusing JSONP endpoints or open redirects on the very domains the policy was meant to trust.

The modern advice is different, simpler in concept, and harder to deploy: use a strict CSP built on nonces or hashes, with 'strict-dynamic' to handle the messy reality of third-party scripts. That's the shape this post takes. We'll start with what CSP actually is, walk through the mechanics, and then get into the parts that bite — nonce vs hash trade-offs, 'strict-dynamic', Trusted Types, reporting, and the deployment process that lets you ship a strict policy without taking the site down.

A note on confidence: most of what follows is straightforward, well-documented platform behaviour. Where there's a genuine trade-off or where browser support is patchy, I've called it out explicitly rather than papering over it.

What CSP is, and what it isn't

CSP is a browser-enforced policy, delivered as an HTTP response header, that tells the browser which sources of content it should trust on a given page. When a CSP is present, the browser refuses to execute scripts, load stylesheets, embed frames, or fetch other resources that don't match the policy. The classic example is preventing an attacker who manages to inject <script src="https://evil.example/x.js"> into your page from actually getting that script to run, because evil.example isn't in your policy.

What CSP is not is a primary defence against XSS. It's defence-in-depth. If an attacker can inject HTML into your page, you already have a bug — CSP just reduces the blast radius. The OWASP and MDN guidance is consistent on this: keep escaping output, keep parameterising queries, keep validating input. CSP is the second line, not the first.

The header itself looks like this:

Content-Security-Policy: default-src 'self'; img-src 'self' https://cdn.example.com; script-src 'self'

Each semicolon-separated chunk is a directive. default-src is the fallback for most fetch directives; the others (script-src, img-src, style-src, connect-src, frame-src, font-src, media-src, worker-src, etc.) override it for specific resource types. There are also a handful of non-fetch directives — base-uri, form-action, frame-ancestors, report-to — which we'll get to.

Why the old allowlist advice aged badly

For years, the canonical advice was to list the domains your site loaded scripts from:

Content-Security-Policy: script-src 'self' https://www.google-analytics.com https://cdn.example.com

It feels intuitive — only run scripts from places you trust. The problem, as a now-famous 2016 Google paper ("CSP is Dead, Long Live CSP!") showed, is that "places you trust" almost always includes at least one domain hosting a JSONP endpoint, an Angular template, or an open redirect. Researchers analysed CSPs on the top million sites and found that around 95% of allowlist-based policies were trivially bypassable.

The replacement is the strict CSP: instead of listing domains, you mark the scripts you intend to run with a nonce or a hash, and tell the browser to execute only those. The 'strict-dynamic' keyword then lets those trusted scripts load further scripts without you having to enumerate every CDN.

A minimal strict CSP looks like this:

Content-Security-Policy:
  script-src 'nonce-r4nd0m' 'strict-dynamic' https: 'unsafe-inline';
  object-src 'none';
  base-uri 'none';
  require-trusted-types-for 'script';

That looks alarming — 'unsafe-inline' and https: are right there. Both are ignored by any browser that understands 'strict-dynamic', which is every current Chromium, Firefox, and Safari. They exist purely as fallbacks for older browsers that didn't get the memo. The browsers that do support 'strict-dynamic' use only the nonce.

Nonces, in detail

A nonce ("number used once") is a random value you generate per request, put in the CSP header, and also stamp on every <script> tag you intend to allow:

Content-Security-Policy: script-src 'nonce-r4nd0m';
<script nonce="r4nd0m">
  // inline script, allowed
</script>

<script nonce="r4nd0m" src="/app.js"></script>

Two things matter here. First, the nonce must be cryptographically random and unguessable, generated fresh per response — at least 128 bits, base64-encoded. A static nonce is worse than no CSP at all, because it gives you a false sense of safety. Second, the nonce attribute on the <script> tag is automatically hidden from JavaScript via the Element.nonce IDL property's special handling — script.getAttribute('nonce') returns an empty string in modern browsers, so a script can't read the current page's nonce and reuse it. (This is a real defence-in-depth feature; older browsers leaked the nonce via the DOM.)

The operational cost is that your server-side rendering has to generate the nonce and inject it everywhere a script tag is emitted. Most frameworks (Next.js, Rails, Django, ASP.NET) now have first-class support for this; if you're hand-rolling, it's a middleware concern.

Hashes

Hashes are the alternative. Instead of marking a script with a nonce, you compute a SHA-256 (or 384, or 512) of the script's contents and put the hash in the CSP:

Content-Security-Policy: script-src 'sha256-V2kaaafImTjn8RQTWZmF4IfGfQ7Qsqsw9GWaFjzFNPg='
<script>
  // contents whose SHA-256 matches the hash above
</script>

Hashes have a real advantage: no per-request generation, no server-side rendering coupling. They work well for a small, fixed set of inline scripts — the kind of thing you might bake into a static site at build time. They fall over when your inline content changes, because a one-byte difference (a different whitespace character, a re-minified bundle) invalidates the hash and the browser blocks the script silently in production.

In practice, I'd reach for hashes when:

  • The site is mostly static and built ahead of time.
  • You have a handful of well-known inline snippets (analytics bootstraps, framework hydration markers) that don't change.
  • You can't easily generate per-request nonces (e.g. fully cached HTML at the CDN edge).

Otherwise, nonces are usually the lower-friction option for dynamic apps.

One subtle point: hashes apply to inline scripts by default. To use a hash to allow an external script (one with a src attribute), the browser needs to compute the hash from the response body — that's allowed in CSP Level 3 and well-supported, but it does mean any change to the external file invalidates the hash, including changes pushed by a CDN you don't control.

'strict-dynamic': the keyword that makes strict CSP usable

The reason strict CSP didn't catch on for years is that real apps load scripts that load other scripts. Your nonce-tagged bootstrap loads webpack chunks. Webpack chunks load lazy-loaded routes. Your tag manager loads a vendor script which loads three more. Tagging every one of these with the request's nonce is impractical.

'strict-dynamic' solves this by saying: a script that the browser trusts (because it has a valid nonce or hash) is allowed to load further scripts, and those scripts inherit the trust. The mechanism is specifically propagation through DOM script element creation — i.e. the trusted script calls document.createElement('script') and appends it. Parser-inserted scripts (raw <script> tags in HTML) still need their own nonce.

Content-Security-Policy:
  script-src 'nonce-r4nd0m' 'strict-dynamic';
  object-src 'none';
  base-uri 'none';

This is the policy you actually want for most modern apps. It's short, it doesn't depend on a domain allowlist, and it composes with bundlers and tag loaders without per-script gymnastics.

The trade-off is honest: 'strict-dynamic' is less strict than tagging every script individually. If a trusted script of yours has an XSS-shaped bug — say, it builds a <script> tag from a URL parameter — 'strict-dynamic' will happily trust the resulting script. That's a real attack surface. The compensating control is to keep the set of trusted bootstrap scripts small and audited, and to use Trusted Types (below) to neutralise the DOM injection sinks that would let an attacker craft such a script in the first place.

Trusted Types

Trusted Types are the newer, more aggressive half of modern CSP. The idea: most DOM-based XSS happens because JavaScript code passes attacker-controlled strings to dangerous sinks — innerHTML, outerHTML, document.write, the src of a script element, eval. Trusted Types replaces those string parameters with typed objects (TrustedHTML, TrustedScript, TrustedScriptURL) that can only be created by explicitly-defined, named policies.

You opt in with a CSP directive:

Content-Security-Policy:
  script-src 'nonce-r4nd0m' 'strict-dynamic';
  require-trusted-types-for 'script';
  trusted-types default app-sanitizer;

Now element.innerHTML = userInput throws a TypeError unless userInput is a TrustedHTML produced by a policy you've registered. In your code:

const policy = trustedTypes.createPolicy('app-sanitizer', {
  createHTML: (input) => DOMPurify.sanitize(input),
});

element.innerHTML = policy.createHTML(userInput); // OK
element.innerHTML = userInput;                    // TypeError

The deployment story is harder than for script-src — almost every legacy codebase has dozens of innerHTML assignments that need to either be refactored or routed through a default policy — but the security gain is substantial. If you're starting fresh, turn it on. If you're retrofitting, deploy it in report-only mode first (more on that below) and grind through the violations.

Browser support is currently Chromium-family only; Firefox is shipping it as of 2025, Safari is not. The directive is harmless in browsers that don't understand it, so there's no penalty to including it.

Beyond script-src: the other directives that matter

A strict CSP isn't just script-src. A handful of other directives carry real weight:

  • object-src 'none' — blocks <object>, <embed>, and <applet>. These are XSS vectors that nobody actually needs in 2026. Set it to 'none' unconditionally.
  • base-uri 'none' (or 'self') — prevents an injected <base> tag from changing the resolution of relative URLs. Without this, an attacker who can inject a single tag can redirect every relative script src on the page to their own server.
  • frame-ancestors 'none' (or a specific origin) — the modern replacement for the X-Frame-Options header. Stops your page being embedded in someone else's iframe; the defence against clickjacking.
  • form-action 'self' — restricts where <form> submissions can be sent. Stops an injected form from posting credentials to an attacker.
  • upgrade-insecure-requests — auto-upgrades http:// subresource URLs to https://. Useful during a migration, harmless after.

A reasonable starting point for a modern app:

Content-Security-Policy:
  script-src 'nonce-{random}' 'strict-dynamic';
  object-src 'none';
  base-uri 'none';
  frame-ancestors 'none';
  form-action 'self';
  require-trusted-types-for 'script';
  report-to csp-endpoint;

That's seven directives doing most of the real work.

Reporting: report-uri, report-to, and the bit that always trips people up

CSP can phone home when it blocks something. There are two mechanisms, and the relationship between them is mildly cursed.

report-uri is the original directive. It posts a JSON blob to whatever URL you specify whenever a violation occurs. It's deprecated in CSP Level 3, but every browser still supports it. The body looks like:

{
  "csp-report": {
    "document-uri": "https://example.com/page",
    "violated-directive": "script-src-elem",
    "blocked-uri": "https://evil.example/x.js",
    "original-policy": "...",
    "line-number": 42
  }
}

report-to is the replacement, part of the more general Reporting API. It's a two-step setup — you declare named endpoints in a Reporting-Endpoints response header, then reference them by name in the CSP:

Reporting-Endpoints: csp-endpoint="https://report-collector.example.com/csp"
Content-Security-Policy:
  script-src 'nonce-r4nd0m' 'strict-dynamic';
  object-src 'none';
  report-to csp-endpoint;

Browsers that support report-to ignore report-uri when both are present. The practical advice as of 2026: specify both. Browser support for report-to is good in Chromium and improving in Firefox and Safari, but it's not universal. Sending both is free and gives you maximum coverage:

Reporting-Endpoints: csp-endpoint="https://report-collector.example.com/csp"
Content-Security-Policy:
  script-src 'nonce-r4nd0m' 'strict-dynamic';
  object-src 'none';
  report-uri https://report-collector.example.com/csp;
  report-to csp-endpoint;

One genuine gotcha: violation reports are attacker-controlled data. The script-sample field in particular can contain user-injected content. Sanitise on the way into your store.

If you don't want to run your own collector, Report URI (Scott Helme's service) is the standard hosted option and does the right things by default.

Report-Only mode: the only safe way to deploy

Trying to write a strict CSP for an existing site by inspection is a doomed exercise. You'll miss the analytics tag that loads on the checkout page only, the experimental flag that injects a script for 1% of users, the marketing pixel that fires from an iframe. The right approach is to deploy the policy in report-only mode first, watch what breaks, and tighten from there.

The Content-Security-Policy-Report-Only header has the same syntax as Content-Security-Policy, but the browser doesn't enforce it — it only generates violation reports. You can run this for weeks alongside a permissive (or no) enforcing policy and collect the full picture of what your site actually loads.

Content-Security-Policy-Report-Only:
  script-src 'nonce-r4nd0m' 'strict-dynamic';
  object-src 'none';
  base-uri 'none';
  report-to csp-endpoint;

A reasonable rollout sequence:

  1. Deploy the policy in report-only mode. Watch reports for a week or two.
  2. Triage the reports. Most will be one of: legitimate third-party scripts you forgot, browser extensions (you can mostly ignore these — chrome-extension://, moz-extension:// URLs), or actual bugs in your own code.
  3. Add nonces to your own scripts where missing. Where third-party scripts are unavoidable, decide whether you trust them enough to either nonce-tag them or accept the propagation through 'strict-dynamic'.
  4. Once the report rate drops to noise, flip the same policy to enforcing mode.
  5. Keep Content-Security-Policy-Report-Only running with a slightly stricter version of the policy. That's your staging ground for the next tightening.

This loop — report-only as a permanent staging policy, with a stricter draft of the next version always running in parallel — is how mature deployments stay strict without breaking things.

Common ways this still goes wrong

A few failure modes I've seen often enough to flag:

Cached HTML and stale nonces. If your CDN caches HTML, every cached response will have the same nonce. That's a static nonce, which is no nonce at all. Either don't cache HTML (cache the API responses behind it instead), or use hashes, or terminate the nonce injection at the edge.

unsafe-inline and unsafe-eval creeping back in. Every legacy build tool, every analytics vendor, every "just add this snippet" widget wants one of these. Saying yes to either undoes most of the policy. 'strict-dynamic' with a nonce gets you out of 'unsafe-inline'; refactoring out eval() (including the implicit eval in setTimeout('code', n)) gets you out of 'unsafe-eval'.

Inline event handlers. <button onclick="..."> is blocked by a strict CSP and there is no nonce or hash escape hatch — you have to refactor to addEventListener. This is the single biggest source of friction when retrofitting old templates.

Browser extensions in reports. Once you start collecting reports, you'll see thousands of violations from chrome-extension:// and moz-extension:// URLs. These are extensions injecting content into your pages and are usually not your problem. Filter them at the collector.

Trusted Types breaks frameworks subtly. If you turn on require-trusted-types-for 'script' and your framework does any DOM mutation through string sinks, things will break in unexpected places — third-party widgets, error overlays, internationalisation libraries. Stage it carefully.

Browser support, briefly

CSP Level 2 is universal. CSP Level 3 features — 'strict-dynamic', 'nonce-...' handling, hash-source for external scripts, report-to — are well-supported in Chromium and Firefox and well-enough in Safari that you can rely on them. Trusted Types is Chromium and recent Firefox. The official source is the MDN compatibility table: developer.mozilla.org/en-US/docs/Web/HTTP/CSP.

The encouraging thing is that CSP is designed to degrade safely: a browser that doesn't understand a directive ignores it. You don't pay a compatibility cost for opting into newer features, only a security cost if a small fraction of users are on browsers that don't enforce them.

The shortest possible advice

If you're starting today, the policy you want is approximately:

Reporting-Endpoints: csp-endpoint="https://your-collector.example.com/csp"
Content-Security-Policy:
  script-src 'nonce-{random}' 'strict-dynamic';
  object-src 'none';
  base-uri 'none';
  frame-ancestors 'none';
  form-action 'self';
  require-trusted-types-for 'script';
  report-uri https://your-collector.example.com/csp;
  report-to csp-endpoint;

Deploy it in report-only mode first. Generate the nonce per request, server-side, from a CSPRNG. Don't fall for the older domain-allowlist pattern. Watch your reports. Tighten over time.

CSP isn't going to stop a determined attacker on its own, but a strict policy turns a lot of would-be XSS bugs into broken pixels and noise in your reporting endpoint — and that's a trade most teams are happy to make.


Further reading: