All articles · Web Security

Shipping a Strict CSP Without Breaking Your Site

Cover with the article title next to a shield with a check mark, in green on a dark background

Content Security Policy has a reputation problem. Teams add a header, the site breaks, someone adds 'unsafe-inline' to make the errors go away, and the policy that ships blocks almost nothing. A year later a security scanner flags it and the cycle starts again.

It doesn’t have to go that way. A strict CSP, based on nonces rather than lists of trusted domains, is both more secure and, once you’ve done the initial cleanup, easier to maintain. This article explains why, and walks through a rollout that never breaks production: report first, enforce later.

Why allowlists don’t work

The classic CSP looks like this:

Content-Security-Policy: script-src 'self' https://www.googletagmanager.com https://cdn.jsdelivr.net

It reads as “only run scripts from these places”. The trouble is that big shared hosts serve a lot of scripts, and some of them can be abused. If an allowlisted CDN hosts an old version of a framework that evaluates templates from the DOM, or an endpoint that returns JSONP with a caller-chosen callback, an attacker who can inject HTML can load that script and get code execution without ever violating the policy. A large 2016 study by Google researchers found that the vast majority of real-world allowlist policies could be bypassed this way.

Allowlists also rot. Every new vendor means another domain; every removed vendor leaves one behind. After a couple of years nobody knows which entries are still needed.

What a strict policy looks like

A strict CSP stops trusting locations and starts trusting individual script tags:

Content-Security-Policy:
  script-src 'nonce-3pXNcR2a9kFQe0w7Lk1dTg' 'strict-dynamic';
  object-src 'none';
  base-uri 'none';
  • 'nonce-…': the server generates a fresh random value for every response and puts it on each script tag it means to run: <script nonce="3pXNcR2a9kFQe0w7Lk1dTg" src="/app.js">. Injected markup can’t know the nonce, so it can’t run.
  • 'strict-dynamic': scripts loaded by a trusted script are trusted too. Your tag manager or module loader can keep adding scripts without you listing every domain. In browsers that support it, host allowlists in script-src are ignored when 'strict-dynamic' is present.
  • object-src 'none' shuts off plugin content, an old bypass route.
  • base-uri 'none' (or 'self') prevents an injected <base> tag from redirecting your relative script URLs to an attacker’s server.

For static sites where you can’t vary the HTML per response, use hashes instead of a nonce: 'sha256-…' for each inline script, plus 'strict-dynamic' so those scripts can load the rest.

Older browsers that don’t understand nonces fall back gracefully if you also add 'unsafe-inline' https:. Modern browsers ignore both when a nonce or 'strict-dynamic' is present, so the fallback costs nothing in browsers that matter.

Nonce rules that are easy to get wrong

  • Fresh per response. A nonce that never changes is just a password published in your HTML. At least 128 bits from a cryptographically secure random generator, base64-encoded.
  • Careful with full-page caching. If a CDN caches HTML, every visitor gets the same nonce until the cache expires. That weakens the protection considerably. Either don’t cache HTML at the edge, regenerate nonces at the edge, or use hashes.
  • Never reflect a nonce from the request, and never put one on a tag whose content comes from user input.

Step 1: find out what will break

Before writing any policy, inventory what runs on your pages:

  • Inline <script> blocks. Each needs a nonce, or should move to an external file.
  • Inline event handlers like onclick="…" and javascript: URLs. A strict CSP blocks them, and there’s no nonce for an attribute. They have to become addEventListener calls.
  • Third-party snippets that inject inline scripts themselves. Most major vendors now document a nonce-friendly integration; some expect you to pass the nonce in.
  • eval() and new Function(), often hiding in older libraries and some template engines. A strict policy blocks them unless you add 'unsafe-eval', which you should treat as a temporary exception with an owner and a date.

Our CSP guide lists what our scanner flags in existing policies, including unsafe-inline, unsafe-eval, wildcards and missing object-src/base-uri.

Step 2: deploy in Report-Only mode

The Content-Security-Policy-Report-Only header evaluates the policy, reports violations, and blocks nothing. It’s how you test against real traffic without risk.

Content-Security-Policy-Report-Only:
  script-src 'nonce-{random}' 'strict-dynamic';
  object-src 'none';
  base-uri 'none';
  report-uri /csp-reports;
  report-to csp
Reporting-Endpoints: csp="/csp-reports"

report-to with the Reporting-Endpoints header is the current mechanism; report-uri is deprecated but still widely supported, so sending both covers every browser.

Expect noise. Browser extensions inject scripts into pages and trigger violations that have nothing to do with your code. Filter reports by source-file and blocked-uri, and ignore schemes like chrome-extension: and moz-extension:. What’s left is the real list of things to fix.

Leave Report-Only running long enough to cover your traffic patterns: checkout flows, logged-in areas, rarely used admin pages and marketing landing pages built by other teams. A week or two is usually enough to see the long tail.

Step 3: fix, don’t loosen

For each real violation, fix the cause instead of widening the policy:

Violation Fix
Inline script without a nonce Add the nonce in the template, or move it to a file
onclick / onload attribute Replace with addEventListener in a script file
javascript: link Use a button with a listener
Vendor snippet injecting code Use the vendor’s nonce option or load it from a nonced loader
eval in a library Upgrade or replace the library

Adding 'unsafe-inline' to make a violation disappear undoes the point of the exercise: with it (and without a nonce), any injected inline script runs.

Step 4: enforce, keep reporting

When the Report-Only stream is quiet apart from extension noise, switch the header name to Content-Security-Policy. Keep the reporting directives: violations in enforce mode are blocked, and the reports are how you’ll notice a broken feature or an attempted injection.

A reasonable way to switch is to run both headers for a while: the strict policy in Report-Only and your existing (weaker) policy enforced, then promote the strict one. Some teams roll out per path or per percentage of traffic first.

Beyond scripts

script-src is the part that stops XSS, but a few other directives are worth adding while you’re there:

  • frame-ancestors 'none' (or 'self') controls who can embed your pages, which is the modern replacement for X-Frame-Options. See clickjacking protection. Note that it only works in a header, not in a <meta> tag.
  • form-action 'self' stops injected forms from posting credentials elsewhere.
  • upgrade-insecure-requests helps while you clean up mixed content.
  • Trusted Types (require-trusted-types-for 'script') go further and lock down DOM sinks like innerHTML in Chromium-based browsers. It’s a bigger project, but it’s the strongest defence against DOM-based XSS available today.

What we do on our own site

PerfoMetrix took a slightly different route that suits small, server-rendered sites: there are no inline scripts, inline styles or inline event handlers at all. Every script is an external file on our own origin, so the policy can stay short (script-src 'self' plus the one bot-protection provider we use, object-src 'none', base-uri 'none', frame-ancestors 'none'). That works because our origin serves no JSONP endpoints or legacy libraries an attacker could reuse. Once a site depends on tag managers and third-party snippets, the nonce approach above is the one that scales.

We, too, started in Report-Only. If you scan our home page today, the CSP check says so plainly: the policy is reported, not yet enforced. That’s what the staging step looks like from the outside.

A strict CSP is one of the few security headers that meaningfully limits the damage when something else goes wrong. It’s worth the cleanup, and with Report-Only, the cleanup doesn’t have to be risky.

Tags:CSPXSSsecurity headersnoncestrict-dynamic