How to deploy a strict Content Security Policy without breaking your app
Google’s 2016 study of 26,011 real-world Content Security Policies found 94.72% could be bypassed, mostly because they trusted hosts rather than specific scripts. A practical guide to deploying the strict alternative: a nonce- or hash-based script-src with 'strict-dynamic', how to choose between them, threading a per-response nonce through server-rendered HTML, refactoring inline handlers and eval, a report-only rollout, Trusted Types for DOM sinks, and the gaps a strict CSP still leaves open.
By The Xalgorix Team
In 2016, a team of Google researchers pulled every Content Security Policy they could find from a search-engine crawl — 26,011 unique policies across 1,680,867 hosts — and checked whether each one would actually stop an attacker with an XSS bug. The answer in “CSP Is Dead, Long Live CSP!” (ACM CCS 2016) was bleak: 94.72% of distinct policies could be bypassed, and 14 of the 15 domains most commonly allowlisted for scripts hosted endpoints an attacker could abuse. Many of those policies were long lists of trusted hosts — and that was the problem.
The fix the same researchers proposed, now Google's published guidance, changes the question a policy asks. Instead of “which hosts may serve script?” it asks “which specific script elements did the server mean to put on this page?” This guide is the engineering side of that: the policy to aim for, how to choose between nonces and hashes, how to thread a nonce through a server-rendered app, what you will have to refactor, how to roll it out without breaking production, and how Trusted Types closes the DOM-sink gap a script policy leaves open. It ends with what a strict CSP still will not stop, because a policy you over-trust is its own kind of vulnerability.
Why host allowlists leak
An allowlist like script-src 'self' https://cdn.example.net https://www.google-analytics.com trusts every URL on those origins, not just the files you meant to load. If any of them serves a JSONP endpoint that reflects a callback name, or hosts an old copy of a framework that evaluates expressions in page markup, an attacker who can inject HTML simply points a <script src> at that URL. The browser checks the host, sees it on the list, and runs it. The CCS paper found exactly this on almost every popular script host. Adding 'unsafe-inline'makes the question moot, since injected inline script then runs regardless — and 'self' is only as safe as every file your own origin will serve, user uploads and legacy endpoints included.
Allowlists also rot. Every new vendor, CDN migration or analytics tag grows the list, and nobody audits what each added origin can be made to return. A nonce- or hash-based policy avoids that whole class of drift: the browser runs a script because the server marked that element, not because of where its bytes live.
The policy to aim for
These are the two policies web.dev recommends, nearly verbatim. Pick one; the next section explains how.
# Server-rendered HTML: a fresh nonce on every response
Content-Security-Policy:
script-src 'nonce-{RANDOM}' 'strict-dynamic';
object-src 'none';
base-uri 'none';
# Static or cached HTML: hashes of the exact inline scripts
Content-Security-Policy:
script-src 'sha256-{HASH_OF_INLINE_SCRIPT}' 'strict-dynamic';
object-src 'none';
base-uri 'none';Each line earns its place:
- The nonce or hash is the root of trust. Only
<script>elements carrying the matchingnonceattribute, or inline scripts whose exact bytes hash to a listed value, may run. Markup an attacker injects has neither. 'strict-dynamic'propagates that trust to scripts a trusted script loads itself, so a nonced loader or tag manager can still add its dependencies withdocument.createElement("script"). Per MDN, when it is present “any allowlist or source expressions such as'self'or'unsafe-inline'will be ignored.” Under the CSP3 rules, that propagated trust covers scripts added through the DOM, not markup written throughdocument.write.base-uri 'none'blocks an injected<base href>, which would otherwise re-point every relative URL on the page — including thesrcof your own nonced scripts — at an attacker's server.object-src 'none'shuts off plugin content (<object>,<embed>), historically a way to run code outsidescript-src's control. web.dev keeps it in the baseline policy.
If you still serve very old browsers, web.dev suggests appending https: 'unsafe-inline' as fallbacks. That is safe in modern browsers because, in web.dev's words, “all browsers that support strict-dynamic ignore the https: fallback” and “all recent browsers ignore unsafe-inline if a CSP nonce or hash is present.” That is a compatibility trade-off, though, not a free win. In a browser that understands nonces but not 'strict-dynamic' (CSP Level 2), MDN notes the same header acts like https: 'nonce-...', so an injected <script src> pointing at any HTTPS host runs without a nonce. Check your real traffic: if those browsers are effectively gone from it, drop https: and get the full nonce-only protection there too.
Nonce or hash: decide by how the HTML is served
The choice is not about taste. It follows from whether every response can carry a value nobody has seen before.
The third box is an easy failure to introduce: a nonce-based policy goes live, then someone puts the HTML behind a CDN cache for performance. The header and the attribute stay in sync, nothing breaks, and the nonce is now a constant anyone can fetch. web.dev's guidance is explicit: use hashes “for HTML pages served statically, or pages that need to be cached.” If a page carries a nonce, mark that response uncacheable by shared caches (for example Cache-Control: private, no-store) and check it at the CDN as well as in the app.
For a hash-based SPA, the usual pattern is one small inline loader whose hash goes in the policy; 'strict-dynamic' then lets it add the bundles:
<script>
// Hash these exact bytes -- whitespace included -- into script-src.
for (const src of ["/assets/vendor.js", "/assets/app.js"]) {
const s = document.createElement("script");
s.src = src;
s.async = false; // keep execution order
document.head.appendChild(s);
}
</script>
# compute the source expression for the header:
# printf '%s' "<loader body>" | openssl dgst -sha256 -binary | openssl base64
# -> script-src 'sha256-<that value>' 'strict-dynamic'; ...Generating and threading the nonce
The requirements are short and non-negotiable. MDN asks for “a random base64-encoded string of at least 128 bits of data from a cryptographically secure random number generator,” generated “differently each time the page loads.” That rules out Math.random(), per-session values, per-deploy values and anything derived from the request. In Express it is one middleware:
import crypto from "node:crypto";
app.use((req, res, next) => {
// 16 bytes = 128 bits from the OS CSPRNG, new for every response.
const nonce = crypto.randomBytes(16).toString("base64");
res.locals.cspNonce = nonce;
const policy = [
// https: and 'unsafe-inline' are legacy fallbacks; drop https: if CSP2-only
// browsers no longer matter to you (they would allow any HTTPS script).
"script-src 'nonce-" + nonce + "' 'strict-dynamic' https: 'unsafe-inline'",
"object-src 'none'",
"base-uri 'none'",
"report-uri /csp-reports", // older browsers
"report-to csp", // Reporting API
].join("; ");
// Start in report-only; switch the header name when violations stop.
res.setHeader("Content-Security-Policy-Report-Only", policy);
res.setHeader("Reporting-Endpoints", 'csp="https://app.example.com/csp-reports"');
res.setHeader("Cache-Control", "private, no-store");
next();
});
// In the template, every script you own gets the attribute:
// <script nonce="<%= cspNonce %>" src="/static/app.js"></script>
// <script nonce="<%= cspNonce %>">window.__BOOT__ = ...;</script>Two details matter beyond the header. First, the nonce goes only on elements the server writes itself; never copy it onto a tag that wraps user content, or you have signed the attacker's script for them. Second, browsers hide the attribute from the DOM: MDN notes that getAttribute("nonce") returns an empty string and only the .nonce property exposes it, which stops CSS attribute selectors from leaking the value. Your own code that legitimately needs it (say, to pass it to a framework) should read script.nonce.
Most SSR frameworks can do this threading for you, so check your framework's CSP documentation before hand-rolling it: the hard part is not generating the value, it is making sure every script the framework emits — hydration data, route chunks, inline bootstraps — receives it.
What a strict policy blocks, and how to refactor it
A strict policy deliberately refuses three patterns that look harmless in your own code and are exactly what injected markup uses:
<!-- Blocked: inline event handlers and javascript: URLs carry no nonce -->
<button onclick="saveDraft()">Save</button>
<a href="javascript:openHelp()">Help</a>
<!-- Allowed: plain markup, behaviour attached from a nonced script -->
<button id="save">Save</button>
<button id="help" type="button">Help</button>
<script nonce="...">
document.getElementById("save").addEventListener("click", saveDraft);
document.getElementById("help").addEventListener("click", openHelp);
</script>
// Blocked without 'unsafe-eval': eval, new Function, setTimeout("string")
const cfg = eval("(" + text + ")");
// Refactor:
const cfg = JSON.parse(text);Inline handlers and javascript: URLs cannot be nonced at all, so they must move into script. For eval, web.dev's advice is to refactor and only fall back to 'unsafe-eval' if you cannot, accepting that it “makes your policy slightly less secure.” Third-party widgets are the usual source of all three; the report-only phase below is how you find them before your users do.
Rolling it out without breaking production
Content-Security-Policy-Report-Only applies the policy without enforcing it; as web.dev puts it, “your CSP doesn't affect your app's behavior” while reports flow in. Send reports with the Reporting API (report-to plus a Reporting-Endpoints header) and keep the deprecated report-uri alongside it: MDN declares both in its own example because report-to “does not yet have full cross-browser support.” Note that report-only cannot be set from a <meta> tag; it has to be a response header.
Triage reports by the blocked resource and the page it came from. Expect noise you cannot fix — browser extensions inject their own scripts into pages, and those show up as violations — and look for the signal that matters: the same blocked inline script or handler across many sessions means real code you missed. When the meaningful reports stop, rename the header to Content-Security-Policy, keep reporting on, and treat a sudden spike after a deploy as a regression to investigate. You can also run both headers at once while you tighten an already-enforced policy.
Before you enforce, paste the final policy into Google's CSP Evaluator, built from the same research, which flags allowlist bypasses and missing directives like base-uri.
Closing the DOM-sink gap with Trusted Types
A script policy decides which <script> elements run. It says nothing about your own trusted code writing an attacker's string into innerHTML, which is how most DOM-based XSS happens. Trusted Types handles that half. With require-trusted-types-for 'script', the browser refuses plain strings at the injection sinks web.dev lists — innerHTML, outerHTML, insertAdjacentHTML, document.write, DOMParser.parseFromString, iframe srcdoc, script src and text, eval, new Function and string setTimeout/setInterval — and only accepts typed values produced by a named policy you wrote.
Content-Security-Policy-Report-Only:
require-trusted-types-for 'script';
trusted-types app-html dompurify;
report-to csp
// The one reviewed place where strings may become HTML:
const htmlPolicy = trustedTypes.createPolicy("app-html", {
createHTML: (input) => DOMPurify.sanitize(input),
});
comment.innerHTML = htmlPolicy.createHTML(userSuppliedHtml); // allowed
comment.innerHTML = userSuppliedHtml; // throws once enforcedThe trusted-types directive is an allowlist of policy names; per Chrome's guide, creating a policy whose name is not listed throws, and because DOMPurify.sanitize() “internally creates a dompurify policy,” that name has to be listed too. A default policy, which the browser applies whenever a plain string reaches a sink, is useful as a temporary migration net that logs offenders; web.dev advises using it sparingly and preferring named policies. Support is no longer Chromium-only: web.dev now lists Chrome and Edge 83, Firefox 148 and Safari 26, with a polyfill for the rest.
What a strict CSP still will not stop
A strict CSP is a strong second line, not a fix for injection. Be honest about where it stops:
- Script gadgets.
'strict-dynamic'trusts whatever your trusted code loads or evaluates. Lekies et al. showed at CCS 2017 that gadgets which turn innocent-looking markup into script execution are “omnipresent in almost all modern JavaScript frameworks,” and can bypass CSP, sanitizers and WAFs alike. Framework template injection (AngularJS expressions being the classic case) is the same problem. - Injection into trusted scripts. If user input lands inside a nonced
<script>body, in thesrcURL of one, or in a script your own code builds dynamically, the policy approved that script before the attacker's bytes arrived. Google's older CSP FAQ estimated strict CSP leaves roughly a quarter of XSS bugs unmitigated for reasons like these. - HTML injection without script.
script-srccontrols script. Injected forms, links and unclosed attributes that pull page content into an attacker-controlled request are outside its scope. - Keywords that reopen the door.
'unsafe-eval', a nonce on a cached page, or a nonce copied onto user-influenced markup each quietly hand back what the policy took away.
That is why the bug itself still has to be found and fixed with contextual encoding, safe DOM APIs and sanitization — covered in our piece on exploitable XSS — and why a tester, human or automated, should judge an XSS finding against the policy actually deployed rather than assume it neutralizes everything. A strict CSP changes how much a missed bug costs; it does not mean the bug is gone.
'strict-dynamic'; object-src 'none' and base-uri 'none'; 128-bit nonce from a CSPRNG, new per response, never on cached HTML; inline handlers, javascript: URLs and eval refactored; report-only with report-to and report-uri until reports go quiet; CSP Evaluator before enforcing; then Trusted Types with named policies, report-only first.Ready to see it prove a bug?
Start scanning on Cloud →