DEV Community

Cover image for Content Security Policy: The Header That Breaks More Sites Than It Protects (Until You Configure It Right)
Sara Casciaro
Sara Casciaro

Posted on

Content Security Policy: The Header That Breaks More Sites Than It Protects (Until You Configure It Right)

Somewhere in almost every team's history there's a deploy that added one header, Content-Security-Policy, and broke the checkout page, the analytics dashboard, and the support chat widget in the same afternoon. The header gets reverted, a ticket gets filed for "someday," and someday rarely comes. Meanwhile the vulnerability class this header exists to stop, cross-site scripting, is still one of the most common ways production applications get compromised.

This is not a reason to skip CSP. It's a reason to understand why the naive rollout fails, and what actually makes one stick.

What CSP is actually deciding

A Content-Security-Policy header tells the browser, for this page, exactly which sources of scripts, styles, images, fonts, and connections are allowed to load. Anything not on the list gets silently blocked, and the browser logs why in the console. That's the entire mechanism. There's no AI, no heuristic, no "looks suspicious" scoring. It's an allowlist, enforced exactly as written.

Which is precisely why it breaks things: the list is never as complete as you think it is on day one.

The default policy nobody should ship

Content-Security-Policy: default-src 'self'
Enter fullscreen mode Exit fullscreen mode

This says: only load resources from your own origin. It looks safe, reads as safe, and immediately breaks the first third-party script tag anyone added eighteen months ago and forgot about. Here's exactly what that looks like in the browser console the moment it happens, verbatim, the way Chrome DevTools actually renders it:

Refused to load the script 'https://js.stripe.com/v3/' because it
violates the following Content Security Policy directive:
"script-src 'self'". Note that 'script-src-elem' was not explicitly
set, so 'script-src' is used as a fallback.

Refused to connect to 'https://api.mixpanel.com/track' because it
violates the following Content Security Policy directive:
"connect-src 'self'".

Refused to load the stylesheet 'https://fonts.googleapis.com/css2'
because it violates the following Content Security Policy directive:
"style-src 'self'".
Enter fullscreen mode Exit fullscreen mode

Three unrelated failures, three different directives, all from one line in a response header. This is the moment most teams revert the deploy. Nobody's error monitoring is watching the browser console by default, so these lines exist for exactly as long as someone happens to have DevTools open, which in production is approximately never, until a customer reports a broken payment button and someone finally opens the inspector to find out why.

Report-only mode is not optional, it's the whole strategy

The header has a report-only variant:

Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-report
Enter fullscreen mode Exit fullscreen mode

In this mode, nothing gets blocked. The browser evaluates the policy as if it were live, and sends a JSON report to the endpoint you specify every time something would have been blocked. The console still logs something, but the wording gives away that nothing actually broke:

[Report Only] Refused to load the script 'https://js.stripe.com/v3/'
because it violates the following Content Security Policy directive:
"script-src 'self'". This will be blocked in the future unless the
site's Content Security Policy is updated.
Enter fullscreen mode Exit fullscreen mode

That [Report Only] prefix and the future tense, "will be blocked", is the entire safety net. Stripe still loads, checkout still works, and you get the exact same diagnostic information you'd get from a real break, without a single real user experiencing one. Run this in production, on real traffic, for at least a full week, ideally two, before ever switching to enforcing mode.

A week matters because usage patterns aren't uniform across seven days. A B2B SaaS product sees different third-party scripts fire on the day the finance team runs month-end reports than on a random Tuesday. A retail site behaves differently the day a marketing campaign goes live and a new tracking pixel gets added by someone in a completely different team who never talked to engineering. Report-only mode is how you find these before a real visitor does.

Reading a violation report correctly

A typical CSP report looks like this:

{
  "csp-report": {
    "document-uri": "https://example.com/checkout",
    "violated-directive": "script-src",
    "blocked-uri": "https://js.stripe.com/v3/",
    "original-policy": "default-src 'self'"
  }
}
Enter fullscreen mode Exit fullscreen mode

The blocked-uri is the domain to allowlist. The violated-directive tells you which directive needs it, not always default-src, since script-src, style-src, img-src, connect-src, and font-src are evaluated independently and each falls back to default-src only if not explicitly set. A common mistake is adding every new domain to default-src regardless of what type of resource it is, which quietly widens the policy far more than necessary. A domain that only serves a font has no business being allowed to execute scripts.

The inline script problem, and why 'unsafe-inline' defeats the point

Most legacy codebases have inline <script> tags and inline onclick handlers scattered everywhere. The tempting fix is:

script-src 'self' 'unsafe-inline'
Enter fullscreen mode Exit fullscreen mode

This makes the errors go away and also makes the policy nearly worthless, because unsafe-inline re-opens the exact door CSP exists to close: an attacker who manages to inject an inline script tag will now have it execute, since the policy explicitly permits inline scripts from anywhere.

The correct fix is a nonce, a random token generated fresh on every single page load and attached both to the header and to each legitimate inline script tag:

Content-Security-Policy: script-src 'self' 'nonce-r4nd0mVaLu3'
Enter fullscreen mode Exit fullscreen mode
<script nonce="r4nd0mVaLu3">
  // this one runs, the nonce matches
</script>
Enter fullscreen mode Exit fullscreen mode

An attacker injecting a script through a form field or a URL parameter doesn't know the nonce for that specific page load, because it changes every time. Their injected script has no matching nonce, and the browser refuses to run it, logging exactly this:

Refused to execute inline script because it violates the following
Content Security Policy directive: "script-src 'self'
'nonce-r4nd0mVaLu3'". Either the 'unsafe-inline' keyword, a hash
('sha256-...'), or a nonce ('nonce-...') is required to enable inline
execution.
Enter fullscreen mode Exit fullscreen mode

This is the actual security property CSP provides, visible as a single blocked execution in the console instead of a compromised page. 'unsafe-inline' throws it away entirely for the sake of a quieter console.

The directive that replaces a header you already know

X-Frame-Options has been the standard way to stop a page from being embedded in someone else's iframe, the defense against clickjacking. CSP has its own version, frame-ancestors, and it does more than the old header ever could, since X-Frame-Options only supports a single value while frame-ancestors accepts a list:

Content-Security-Policy: frame-ancestors 'self' https://checkout.partner.com
Enter fullscreen mode Exit fullscreen mode

This allows your own site to embed the page, plus one specific named partner domain, and blocks everyone else. X-Frame-Options can't express "allow these two specific origins," only "allow none" or "allow only my own origin," which is exactly why teams end up disabling the header entirely the moment they need a legitimate embed from one partner. When someone else tries anyway, the embedded page's own console logs:

Refused to frame 'https://example.com/checkout' because an ancestor
violates the following Content Security Policy directive:
"frame-ancestors 'self' https://checkout.partner.com".
Enter fullscreen mode Exit fullscreen mode

Worth keeping both headers during a transition period rather than dropping X-Frame-Options immediately: older browsers that don't evaluate frame-ancestors still respect the legacy header, so the two work as a graceful fallback pair rather than a straight replacement.

Third-party scripts that load other scripts

The hardest category to allowlist correctly is a third-party script that then dynamically loads more scripts from domains you don't control and can't predict in advance, a common pattern in ad tech and some analytics suites. Allowlisting the first domain isn't enough, because the second-order request comes from wherever that first script decides to fetch from, which can change without any deploy on your side.

There is no fully clean answer here. The two realistic options are strict-dynamic, which lets a nonce-verified script propagate trust to scripts it loads itself, or accepting that this specific third-party integration sits outside a strict policy and isolating it in an iframe with its own, separate, looser policy, so a compromise there doesn't inherit the trust level of the main page.

Shipping it without the two-week outage

The rollout that actually survives contact with production looks like this: report-only for at least a week against real traffic, a review pass on every distinct blocked-uri in the reports, an explicit decision per domain about which directive it belongs under, a nonce-based rewrite of any inline script that isn't going away, and only then a switch to the enforcing header. Skipping straight to enforcing mode is the single most common reason CSP rollouts get reverted within a day and never attempted again.

The header takes an afternoon to write. Making it something a browser can actually enforce without breaking checkout is the part that takes two weeks, and skipping that part is why so many production sites either have no CSP at all or ship one so permissive it protects against nothing.

Top comments (0)