Two facts decide the whole design: an allowlist of hosts is usually bypassable, and a policy you cannot deploy without breaking checkout is worth nothing.

What CSP stops, and what it does not

CSP is a response header listing where content may come from and, more importantly, which scripts may run. The browser enforces it. Its real value is not preventing injection but preventing execution: an attacker gets their string into your page, and the browser refuses to run it.

What it does not do is worth stating plainly, because CSP gets sold as a general defence. It does not stop server-side injection, SQL injection, CSRF, or an attacker who compromises a script you have already trusted. It does not sanitise anything. And it is a second line of defence, not a substitute for encoding output correctly.

The directives you will actually spend time on:

DirectiveJob
script-srcwhich scripts may run. This is the one that matters
object-srcplugins. Set to 'none' and never think about it again
base-uristops an injected <base> tag redirecting every relative URL
frame-ancestorswho may frame you, replacing X-Frame-Options
form-actionwhere forms may post, which blocks a common exfiltration route
connect-srcfetch, XHR, WebSocket and beacon destinations
style-srcstylesheets and inline styles, usually the messiest one

base-uri deserves the attention it never gets. Without it, an injected <base href> repoints every relative script URL on the page at another origin, which turns a markup injection into script execution without touching script-src at all.

The allowlist that does not work

The obvious first policy is a list of hosts: your own origin, your CDN, the analytics vendor, the tag manager. It reads like a firewall rule and it is mostly theatre.

The reason is that "this host may serve scripts" is not the same as "this host serves only scripts I intended". Large CDNs host JSONP endpoints whose callback parameter is attacker-controlled, framework builds with known gadget chains, and thousands of libraries any of which might do something useful for an attacker. The 2016 research that led to strict-dynamic measured this across real policies in the wild and found roughly 95% of allowlist-based ones trivially bypassable. Adding 'unsafe-inline' on top, which most policies do because removing inline scripts is work, means the policy stops almost nothing.

The alternative is to stop identifying scripts by where they came from and start identifying them individually, by nonce or by hash. That is the whole idea behind a strict policy, and it is why a good CSP is short rather than long.

Nonces, hashes and strict-dynamic

A nonce is a random value generated per response. It goes in the header and on each script tag you trust:

Content-Security-Policy: script-src 'nonce-r4nd0mV4lu3' 'strict-dynamic';

with <script nonce="r4nd0mV4lu3" src="/app.js"> in the markup. An injected script has no nonce and does not run. Two rules make or break it: the value must come from a CSPRNG, and it must be different on every response. A nonce baked into a cached HTML page is a constant, and a constant an attacker can read is not a secret.

A hash identifies a script by its content: the base64-encoded SHA-256 of the exact bytes between the tags, written as 'sha256-...'. No per-request state, so it works on fully static hosting and cacheable HTML. The cost is that a single whitespace change invalidates it, which means generating hashes must be part of the build rather than a manual step. If you need one by hand, our SHA-256 generator has a base64 flag that gives you exactly the form the directive expects.

strict-dynamic is the piece that makes either approach survive a real bundler. It says a script loaded by an already-trusted script is trusted as well, so a nonced entry point can load its own chunks without you listing every path. In browsers supporting CSP Level 3 it also causes host allowlists, https: and 'unsafe-inline' in that directive to be ignored, which is why the recommended policy keeps them: modern browsers ignore them and older ones use them as a fallback.

The same fallback logic explains the most common CSP support question. Adding a nonce or hash makes browsers ignore 'unsafe-inline' in that directive. So if your inline script stopped working the moment you introduced nonces, the policy is behaving exactly as specified.

Report-only, the whole point of it

Enforcing a policy you have not measured is how CSP gets rolled back on a Friday. Content-Security-Policy-Report-Only evaluates the policy against real traffic, reports every violation and blocks nothing.

Both headers can be sent at once, and that is the mechanism for a continuous rollout rather than a single migration:

  1. Send the intended policy in report-only and leave it for a week or two, long enough to cover an ordinary release cycle and a marketing campaign.
  2. Read the reports, separate your own violations from third-party noise, fix yours.
  3. Move that policy to enforcing.
  4. Put the next, stricter policy into report-only alongside it, and repeat.

Reporting itself is in an awkward transitional state. report-uri is deprecated and still the only mechanism Safari supports; report-to with the Reporting-Endpoints header is the replacement and is where Chrome is going. Send both, since a browser that understands report-to ignores report-uri. Whichever endpoint you use has to accept unauthenticated POSTs from anywhere and will receive a great deal of junk, so rate-limit it and never let a report body reach a template unescaped.

Reading reports without drowning

The first day of reports is a shock, and most of what you see is not yours. Browser extensions inject scripts into your pages. Antivirus products rewrite responses. Mobile carriers and hotel networks inject their own content. In-app browsers add instrumentation. All of it violates your policy and all of it gets reported.

Triage by blocked-uri:

  • chrome-extension:, moz-extension:, safari-extension:: extensions, ignore.
  • Your own origin, or inline: real, fix it.
  • eval: real, and usually one dependency doing something old-fashioned.
  • Unfamiliar third-party hosts: check once, then filter. Injected junk dominates this bucket.
  • about or an empty value: usually a browser quirk, and not actionable.

Two more things worth knowing before you draw conclusions from the numbers. Browsers truncate and coarsen report fields deliberately, so script-sample gives you the first forty characters and the line number can be wrong for bundled code. And report volume differs enormously between browsers, so a Safari-heavy audience will look artificially clean.

The six things that break

  • Inline event handlers. onclick="..." in markup is blocked by any policy without 'unsafe-inline', and a nonce cannot rescue an attribute. Move them to addEventListener. CSP Level 3's 'unsafe-hashes' exists for the ones you cannot move, and it is a compromise rather than a fix.
  • javascript: URLs. Same story, common in legacy markup and in some CMS output.
  • eval() and friends. new Function(), setTimeout("string") and several older template engines all need 'unsafe-eval'. Some WebAssembly toolchains need 'wasm-unsafe-eval', which is narrower and much safer.
  • Inline styles. CSS-in-JS libraries write style attributes at runtime, and a nonce cannot cover an attribute. Many teams end up with style-src 'unsafe-inline'; it is a far smaller risk than the script equivalent, and worth being honest about rather than pretending otherwise.
  • Tag managers. Google Tag Manager injects scripts, so it needs either the nonce propagated into it or strict-dynamic to cover what it loads. Custom HTML tags written by marketing will violate the policy the moment someone publishes one, which is a process problem as much as a technical one.
  • Third-party widgets. Chat, payments, maps and A/B testing each pull in their own hosts, and their documented CSP requirements are frequently out of date. This is where report-only earns its keep.

A policy worth starting from

For a server-rendered application that can generate a nonce per response:

Content-Security-Policy: default-src 'self'; script-src 'nonce-{random}' 'strict-dynamic' https: 'unsafe-inline'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'; require-trusted-types-for 'script'

Read the script-src line right to left and it stops looking odd. 'unsafe-inline' is for browsers too old for nonces, https: for browsers too old for strict-dynamic; both are ignored by anything current. base-uri 'none' and object-src 'none' close two holes for free. require-trusted-types-for 'script' is the newest piece and addresses DOM XSS by requiring a policy object for dangerous sinks; it is Chromium-only for now and worth adopting in report-only.

For static hosting with no per-request nonce, replace the nonce with build-time hashes and accept that every script change means a policy change. That is a build step, not a deploy-time decision.

CSP does not stand alone. frame-ancestors covers clickjacking, cookie attributes cover CSRF (which is SameSite, Secure, HttpOnly), and connect-src constrains where data can be sent, which is a different question from which origins may read your API, the subject of CORS errors explained. The response headers around it, from HSTS to the referrer and permissions policies, are collected with their real-world caveats in our HTTP header reference. The specification itself is CSP Level 3, and it is more readable than its reputation suggests.

One last piece of advice from having done this on a live site: put the header behind a config value you can change without a deploy. The first time an enforcing policy blocks something on a browser you did not test, you want the fix to take thirty seconds.

CSP questions during rollout

Why is my inline script still blocked when the policy has unsafe-inline?

Because a nonce or a hash is present in the same directive, and that makes browsers ignore unsafe-inline entirely. It is deliberate: the fallback exists so an old browser that understands neither nonces nor hashes still runs your scripts, while a modern one enforces the stricter rule. So a policy with script-src 'unsafe-inline' 'nonce-abc123' allows exactly the scripts carrying that nonce. Either give the script the nonce or remove the nonce from the directive.

What is the difference between a CSP nonce and a hash?

A nonce is a random value generated per response, sent in the header and repeated as an attribute on each script tag you trust; a hash is the base64 SHA-256 of the script content itself, listed in the policy. Nonces need a dynamic server, since reusing one across responses removes the whole protection, and they suit server-rendered pages. Hashes work on fully static, cacheable HTML, cost nothing at runtime, and have to be regenerated whenever the script content changes by even one byte.

Does a host allowlist in script-src actually protect against XSS?

Much less than it looks. The 2016 study behind strict-dynamic examined real policies and found roughly 95% of allowlist-based ones trivially bypassable, because popular CDNs host JSONP endpoints, Angular builds and other files that turn "load a script from this host" into "run any code". If your policy allows a large CDN or an analytics host, assume an attacker with an injection point can find a callable file there. Nonces or hashes plus strict-dynamic are the answer, not a longer allowlist.

What does strict-dynamic do?

It says that any script loaded by an already-trusted script is trusted too, which lets a nonced bootstrap load its dependencies without you enumerating hosts. In a browser that supports CSP Level 3 it also makes host allowlists, https: and unsafe-inline in that directive be ignored, so you can keep them in the policy purely as a fallback for older browsers. It is what makes a strict policy survive contact with a bundler that injects scripts at runtime.

How do I roll out CSP without breaking the site?

Ship it as Content-Security-Policy-Report-Only first. The browser evaluates the policy, reports every violation and blocks nothing, so you can run the intended policy against real traffic for a couple of weeks before enforcing anything. Then read the reports, fix your own violations, enforce, and immediately put the next, stricter policy back into report-only alongside it. Both headers can be sent at once, which is what makes this an iteration rather than a one-off migration.

Why are my CSP reports full of violations I cannot reproduce?

Browser extensions, injected antivirus scripts, ISP-injected content and in-app browsers all trigger reports against your policy from code you never shipped. On a consumer site the majority of reports are usually this. Filter on the blocked URI: chrome-extension:, moz-extension:, safari-extension: and unfamiliar third-party hosts are almost always noise, while violations naming your own origin or an inline script are almost always real. Firefox and Safari also differ in how much they report, so counts are not comparable across browsers.

Does CSP replace X-Frame-Options?

frame-ancestors supersedes it, and where both are present browsers use frame-ancestors and ignore X-Frame-Options. It is also more capable: it accepts a list of origins and wildcards, where X-Frame-Options only ever really supported DENY and SAMEORIGIN. Keep sending X-Frame-Options as well if you care about clients too old for CSP Level 2, and note that frame-ancestors cannot be set in a meta tag, only in the HTTP header.

Can I set a Content-Security-Policy in a meta tag?

You can, with http-equiv="Content-Security-Policy", and it is a reasonable option on static hosting where you cannot control response headers. Three directives do not work there: frame-ancestors, report-uri and sandbox are ignored in a meta tag. The tag must also come before any content it should govern, since anything the parser has already run is out of reach. Prefer the header wherever you have one.