Shipping a Strict CSP Without Breaking Your Site

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 inscript-srcare 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="…"andjavascript:URLs. A strict CSP blocks them, and there’s no nonce for an attribute. They have to becomeaddEventListenercalls. - 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()andnew 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 forX-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-requestshelps while you clean up mixed content.- Trusted Types (
require-trusted-types-for 'script') go further and lock down DOM sinks likeinnerHTMLin 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.