Skip to main content
Security

Content Security Policy: writing a CSP header that actually works

Start in Report-Only, read the violations, then enforce. A directive-by-directive CSP build for a real site, the unsafe-inline vs nonce vs hash decision, and the mistakes that quietly gut the policy.

Thien Nguyen
By Thien Nguyen
Updated August 2, 2026 · 10 min read
Refused to execute inline script because it violates the following
Content Security Policy directive: "script-src 'self'". Either the
'unsafe-inline' keyword, a hash ('sha256-Xn8Rr...'), or a nonce
('nonce-...') is required to enable inline execution.

Nothing is broken. You shipped a policy saying scripts may only be loaded from files on your own origin, then shipped a <script> tag with code inside it, and the browser did exactly what you asked. The error even lists your three escape hatches — in roughly descending order of how much damage each one does.

The fastest fix is to paste 'unsafe-inline' into script-src and move on. That turns off the single largest thing CSP does for you, which is a strange outcome for a header you added on purpose. The better fix takes a day and starts before you ever send an enforcing header.

Send it in Report-Only first

Content-Security-Policy-Report-Only takes the exact same value as Content-Security-Policy. The browser evaluates the policy, logs every violation, and blocks nothing. Same policy, different header name — that is the entire difference.

Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; report-uri https://csp.example.com/reports; report-to csp-endpoint
Reporting-Endpoints: csp-endpoint="https://csp.example.com/reports"

Two reporting directives, deliberately. report-uri is deprecated in CSP Level 3 in favour of report-to, and browsers that support report-to ignore report-uri entirely — but the coverage isn't universal yet, so MDN's own guidance is to specify both until it is. report-to names a group defined by a separate Reporting-Endpoints header; the directive value is a token, not a URL.

Leave it running for a week on real traffic. Then read what came back, because the reports are the actual work:

  • blocked-uri: inline — an inline <script> or <style>. This is the pile you have to make a decision about.
  • blocked-uri: eval — something is calling eval() or new Function(). Usually a dependency, not your code.
  • A third-party host you forgot — the font CDN, the analytics beacon, the payment iframe.
  • Noise you cannot fix — browser extensions inject scripts into your page and generate violations against your policy. Filter by source-file starting with chrome-extension:// or moz-extension:// before you panic at the volume.

Report-Only is a staging area, not an address. You can send both headers on the same response — an enforcing policy you already trust plus a stricter Report-Only one you're still testing. That's the migration path: tighten in Report-Only, promote to enforcing, repeat.

Building the policy, directive by directive

Take a small marketing site: self-hosted, Google Fonts, Plausible for analytics, Stripe Checkout in an iframe, images on a CDN. Here's the whole policy, wrapped for readability — a real header is one line:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' https://plausible.io https://js.stripe.com;
  style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
  font-src 'self' https://fonts.gstatic.com;
  img-src 'self' data: https://res.cloudinary.com;
  connect-src 'self' https://plausible.io;
  frame-src https://js.stripe.com https://hooks.stripe.com;
  object-src 'none';
  base-uri 'self';
  form-action 'self';
  frame-ancestors 'none';
  upgrade-insecure-requests

Why each line is there:

  • default-src 'self' is the floor. Every fetch directive you don't list falls back to it, so a directive you forgot fails closed instead of open. Start here and only loosen where a report told you to.
  • script-src names the two third parties that genuinely execute code. Google Fonts is not one of them — it ships a stylesheet, not a script. Most vendors publish their own CSP requirements; check the docs rather than guessing hostnames from the network tab.
  • style-src 'unsafe-inline' is the compromise I'd actually make. Component libraries and font loaders set style="" attributes and inject <style> blocks constantly, and a stylesheet cannot execute code. Accepting 'unsafe-inline' for styles while refusing it for scripts is a defensible line, not a cop-out.
  • font-src is the one everyone forgets. Google Fonts serves the stylesheet from fonts.googleapis.com and the font files from fonts.gstatic.com. Allowlist only the first and your text silently falls back to a system font.
  • img-src data: covers inlined SVGs and base64 placeholders, which almost every build pipeline emits somewhere.
  • connect-src governs fetch, XHR, WebSocket and sendBeacon. default-src 'self' quietly covers it until the day you add analytics or error reporting, and then it's the directive nobody thinks to check.
  • frame-src needs two Stripe hosts — the checkout frame and the 3-D Secure challenge frame come from different origins. Payment providers do this routinely.
  • object-src 'none' is a free win that kills a family of legacy plugin injection vectors.
  • base-uri 'self' stops an injected <base> tag from repointing every relative URL on the page at an attacker's host.
  • form-action 'self' stops an injected form from POSTing your users' credentials somewhere else.
  • frame-ancestors 'none' is your clickjacking defence — see below.
  • upgrade-insecure-requests only means anything on an HTTPS origin. On a local build served over plain HTTP it will happily upgrade same-origin requests to an https://localhost that has no TLS listener, and every client-side fetch dies.

The takeaway: base-uri, form-action and frame-ancestors do not fall back to default-src. A policy of default-src 'none' looks maximally paranoid and still leaves your page framable by anyone on the internet.

Once you know why each directive is there, don't hand-assemble the string — a misplaced semicolon or a stray comma between sources is a policy that parses into something you didn't mean. Our CSP Generator takes per-directive source lists and emits the header value plus the equivalent meta tag. And because Report-Only takes the identical value, you build the policy once and only change which of the two header names you ship it behind.

unsafe-inline vs nonce vs hash

This is the decision the opening error message is really asking you to make.

'unsafe-inline' is blanket permission for every inline script on the page, including the one an attacker just injected into your comment field. It's not a weaker version of the protection; for the XSS class CSP exists to stop, it's the off switch.

A nonce is a random token generated per response, sent on the header (script-src 'nonce-r4nd0m') and repeated on each <script nonce="r4nd0m"> you trust. Injected markup doesn't know this request's value, so it doesn't run. This is the strong option — and it requires HTML generated per request.

A hash is the base64 SHA of a script's exact bytes: script-src 'sha256-...'. No per-request anything, which makes it the right tool for a handful of stable inline scripts. It's brittle the moment the content changes, and it does not cover inline event handlers like onclick="" — those need 'unsafe-hashes', and the real fix is addEventListener.

Two behaviours worth knowing before you design around them, both from the spec:

  • Adding a nonce or a hash to a directive makes the browser ignore 'unsafe-inline' in that same directive. So you can leave 'unsafe-inline' in as a fallback for ancient browsers without weakening modern ones — it's dead weight, not a hole.
  • 'strict-dynamic' propagates trust from a nonce'd or hashed root script to whatever that script loads, and in exchange ignores host allowlists and 'self' entirely. Great for tag managers. Also a genuine trap: your carefully curated list of allowed hosts stops doing anything the moment it appears.

Why a static site can't just use nonces

A nonce is only worth something if it's unpredictable per response. Prerendered output is one HTML file served to everyone, so a nonce baked in at build time is a constant — and a constant nonce is 'unsafe-inline' with extra steps, since injected script can read it off any existing tag. Making it per-request means rendering per request, which is the thing you gave up to be static.

It gets more concrete with modern frameworks. Next.js App Router emits inline hydration and bootstrap scripts (self.__next_f.push(...)) whose contents differ per page and per build, so there's no stable hash to pin either. If you prerender, script-src 'unsafe-inline' is frequently the honest answer. The useful move is to be exact about what it costs and what caps the damage — an httpOnly session cookie that injected script can't read via document.cookie, plus object-src 'none', base-uri 'self' and form-action 'self' held firm — rather than pretending you'll get to nonces next quarter.

frame-ancestors vs X-Frame-Options

X-Frame-Options speaks two words: DENY and SAMEORIGIN. There was a third, ALLOW-FROM origin, and it's worse than unsupported — MDN documents that a modern browser encountering it will ignore the header completely, so a page trying to allow one specific framer that way ends up allowing all of them. frame-ancestors takes a full source list, so it can say "these three partner origins and nobody else" — the thing XFO structurally cannot express.

The gap that actually bites: XFO cannot say "allow any origin." If you run an embeddable surface — a widget, an oEmbed target, a public embed route — the only way to permit framing with XFO is to not send the header at all on that path, and use frame-ancestors * to state the intent explicitly. Getting this wrong means your embed works in dev and renders a blank frame on every customer site.

Sending both is fine and costs nothing, as long as the two agree — and as long as you remember XFO is the one that can't be scoped, so any path where they'd disagree is a path where XFO should simply be absent. Two more frame-ancestors quirks: it does not work in a <meta http-equiv> tag, header only — and, as above, it has no default-src fallback.

Common mistakes

Shipping 'unsafe-eval' to unblock a dependency, then never removing it

Some third-party script calls eval() or new Function(), throws EvalError under your policy, and the ten-second fix is 'unsafe-eval'. It ships and it stays. The subtler version: dev tooling needs 'unsafe-eval' for Fast Refresh and HMR, so if your dev and prod policies share a code path with a loose isDev branch, the relaxation leaks. Worse, a dev policy containing 'unsafe-eval' masks a missing 'wasm-unsafe-eval' — your WebAssembly feature works perfectly on localhost and dies in production. Verify WASM and eval behaviour against the header prod actually sends, never the dev one.

Allowlisting https: in script-src

script-src 'self' https: means "any script from any host on the internet, as long as it's TLS." You keep the protections against data: URLs and inline injection; you abandon the origin allowlist, which was most of the point.

Sometimes it is genuinely the right call — programmatic ad stacks pull from a hundred-plus rotating demand-partner hosts that nobody can enumerate ahead of time, and a per-host list would lag reality permanently. If you go there, keep default-src, object-src, base-uri and form-action pinned to 'self', make sure session cookies are httpOnly, and write the reasoning down next to the config. The difference between accepting a risk and shipping one by accident is entirely whether anybody wrote down why.

Sending two CSP headers on the same response

Multiple enforcing CSP headers are all enforced, and the effective policy is their intersection — not "last one wins." This bites when you add a route-specific relaxation and leave the catch-all in place: the catch-all still says script-src 'self', your new entry says script-src 'self' https://cdn.example.com, and the CDN script is still blocked. Confirm with curl -sI https://example.com/your-route/ and count the content-security-policy lines. There should be exactly one.

Assuming a strict default-src covers framing

Covered above and worth repeating because it's the most common false sense of security in this whole header: default-src 'none' does nothing for clickjacking. frame-ancestors is a navigation directive with no fallback.

Never leaving Report-Only

Report-Only enforces nothing, so a policy that has sat in Report-Only for six months is providing exactly zero security while producing a dashboard that looks like it is. It's worse than no policy, because someone will point at the dashboard in a review. Set a date, fix the top three violations, change the header name.

Ship the strict one

The order that works: write the policy you actually want, send it as Content-Security-Policy-Report-Only with a reporting endpoint, spend a week reading violations, loosen only where a report forced you to, then rename the header. Every loosening should have a comment next to it saying which report caused it — that comment is what lets someone delete the line in a year when the dependency is gone.

If you're starting from a blank config, build the header from structured directive fields rather than a text editor, so the only thing you have to get right is the reasoning.

Cover photo by David McElwee on Pexels.

References

Primary documentation and specifications checked when this article was last updated.

SecurityWebHTTP

Related articles

All articles