Syed Hasibur Rahman28 min read
The Allowlist Was the Bug: Strict Content Security Policy, from Nonces to Trusted Types
Most Content Security Policies in the wild do nothing against cross-site scripting. Google's 2016 study of 26,011 unique policies found 94.72% of them bypassable, mostly because host allowlists trust entire domains that serve JSONP endpoints and old framework builds. The fix the web platform converged on is a strict policy: trust individual scripts by nonce or hash, let 'strict-dynamic' propagate that trust to what they load, and use Trusted Types to lock the DOM sinks those scripts could still misuse. This post walks the CSP Level 3 and Trusted Types specs directive by directive, with working Go, Python, and JavaScript, a rollout plan built on report-only mode, and an honest account of what the model does not cover.
The penetration test report ran to forty pages, and the finding that mattered was on page eleven. A stored cross-site scripting bug in a comment field — a sanitizer that let one attribute through that it should not have. The engineering lead was not especially worried. “We have a Content Security Policy,” she wrote in the ticket. “Inline script is blocked. This is low severity.”
The tester’s reply was two lines of markup. The site’s policy allowed scripts from its own origin and from a large public CDN, because the frontend had loaded a charting library from there years ago. That CDN, like every large public CDN, also hosted a decade of old framework releases, including builds of a template engine that evaluates expressions it finds in ordinary HTML attributes. The tester did not need inline script. They loaded a perfectly legitimate, perfectly allowlisted file, and let it execute the payload hiding in the markup the sanitizer had considered harmless. The policy was enforced. The browser did exactly what it was told. The attack worked anyway.
This is not an exotic edge case. It is the typical outcome, and it has been measured. When Google researchers analysed the policies deployed across roughly a billion hostnames in 2016, they concluded that 99.34% of hosts with CSP used policies that offered no benefit against XSS1. The problem was not that developers wrote their policies carelessly. The problem was the model: a list of trusted places is a list of places an attacker can shop in.
The web platform has spent the decade since building a better model. Trust individual scripts, not domains. Let those scripts carry their trust to what they load. And, as of February 2026, when Firefox 148 shipped it as the last major engine, lock down the DOM APIs through which trusted scripts can still be turned against you — Trusted Types is now Baseline across Chrome, Edge, Firefox, and Safari2.
This post is the whole path. We start with what CSP is actually for, show why the allowlist failed, build nonce- and hash-based policies with working code, explain 'strict-dynamic' precisely, admit what a strict policy cannot stop, and then close that gap with Trusted Types. We finish with a rollout plan that does not break production, a note for anyone taking card payments, and what to do on Monday.
Part 1: What CSP is for, stated narrowly
Content Security Policy is an HTTP response header — Content-Security-Policy — that tells the browser which resources a page may load and execute. The mechanism dates to 20103; the current specification is Content Security Policy Level 3, a W3C Working Draft edited by Mike West and Antonio Sartori, most recently published on 13 August 20264.
CSP has accumulated many jobs: framing control (frame-ancestors), form targets (form-action), mixed-content upgrades, sandboxing. But the job that justifies its complexity is one: mitigating cross-site scripting. XSS is not a legacy problem. In MITRE’s 2025 CWE Top 25, CWE-79 — improper neutralization of input during web page generation — is ranked number one, with a score of 60.385.
The important word is mitigating. CSP does not fix XSS. The bug is still in your templates, your sanitizer, or your client-side rendering. CSP is the second wall: when an attacker succeeds in injecting markup, the policy decides whether that markup can become running code. A good policy turns a critical finding into a cosmetic one. A bad policy — the kind the pentest above found — turns into a false sense of safety that downgrades the ticket.
So the only question worth asking of a policy is this: if an attacker can inject arbitrary HTML into this page, can they execute JavaScript? Everything below is in service of making the answer “no.”
Part 2: Why the allowlist failed
The original CSP model was an allowlist of sources:
Content-Security-Policy: script-src 'self' https://cdn.example-cdn.com https://analytics.example.com
The intuition is appealing. Scripts from my origin, my CDN, and my analytics vendor are fine; anything else is blocked; inline script is blocked unless I write 'unsafe-inline'. An attacker who injects <script>alert(1)</script> gets nothing.
The problem is that an allowlist entry trusts every URL on that host, forever, including URLs you have never looked at. Weichselbaum, Spagnuolo, Lekies, and Janc quantified exactly how bad this is in CSP Is Dead, Long Live CSP! at ACM CCS 20161. They examined about 100 billion pages across more than a billion hostnames, finding 1,680,867 hosts with CSP and 26,011 unique policies. Their findings:
- 94.72% of all distinct policies could be bypassed.
- 75.81% of distinct policies used script allowlists that let attackers bypass CSP.
- 14 of the 15 domains most commonly allowlisted for scripts hosted unsafe endpoints.
- 99.34% of hosts with CSP used policies offering no benefit against XSS.
The “unsafe endpoints” are mostly two families. The first is JSONP: an endpoint that wraps a response in a caller-chosen function name, so <script src="https://allowed.example/api?callback=anything"> executes attacker-chosen code from an allowlisted URL. The second is libraries with expression evaluators: an old template engine, loaded from an allowlisted CDN, will happily find and run expressions in injected markup that contains no <script> tag at all. That is the attack from the opening.
A longitudinal study four years later showed the allowlist also rots over time. Roth, Barron, Calzavara, Nikiforakis, and Stock used the Internet Archive to reconstruct CSP deployment on 10,000 highly ranked domains over years, and found, among other things, that even allowlists that looked secure could be circumvented through expired or typo domains — hosts the policy still trusted, which anyone could register3. The same paper documents the long struggle operators face rolling CSP out at all, and concludes that its perceived complexity keeps many sites from even the simpler, functional parts.
The researchers’ conclusion in 2016 was the one this whole post builds on: stop trusting places, and trust specific scripts instead. They proposed the 'strict-dynamic' keyword to make that practical1. It is now part of CSP Level 3 and shipped in every major engine — Chrome 52, Firefox 52, Safari 15.46.
Part 3: Nonces — trust the markup you wrote
A nonce-based policy says: execute a script only if its <script> element carries a secret value that the server generated for this response.
Content-Security-Policy: script-src 'nonce-rAnd0mV4lu3PerResp0nse'; object-src 'none'; base-uri 'none'
<script nonce="rAnd0mV4lu3PerResp0nse" src="/static/app.js"></script>
<script nonce="rAnd0mV4lu3PerResp0nse">window.APP_CONFIG = { locale: "bn-BD" };</script>
The attacker who injects markup does not know the nonce, because it did not exist until the server rendered this response. Their <script> has no nonce, or a stale one, and does not run. Crucially, this is also true of <script src="https://allowed-cdn/old-framework.js">: there is no host allowlist to shop in.
Everything depends on the nonce being unguessable and single-use. CSP Level 3 is explicit in its security considerations: nonces SHOULD be unique per response and should contain at least 128 bits of entropy from a cryptographically secure random number generator4. The web.dev guidance says the same — a cryptographically strong random value, ideally 128 bits or more, newly generated for every response, base64-encoded7.
This is where real deployments fail. Golinelli, Bonomi, and Crispo measured nonce-based policies in the wild in 2023: of 2,271 sites deploying a nonce-based policy, 598 reused the same nonce value in more than one response8 — roughly one in four. A nonce that repeats is a static password printed in the page source. The usual causes are architectural rather than cryptographic: a nonce generated once at process start, or a page rendered with a fresh nonce and then stored in a shared cache that serves the same HTML — and the same nonce — to everyone.
The browser does one thing to help. Since an attacker who can inject CSS could try to read a nonce out of the DOM with attribute selectors, the HTML Standard hides it: when an element becomes connected, if the document has a header-delivered CSP and the element has a non-empty nonce content attribute, the attribute is cleared, while the value survives in an internal [[CryptographicNonce]] slot that script loading still uses. The stated purpose is to “prevent exfiltration of the nonce value through mechanisms that can easily read content attributes, such as selectors”9. Note the condition: the protection applies to policies delivered by header, which is one more reason to prefer the header over <meta>.
Here is a minimal Go middleware that does it correctly. It generates 144 bits per response, sets the policy, marks the response uncacheable by shared caches, and hands the nonce to templates through the request context.
package csp
import (
"context"
"crypto/rand"
"encoding/base64"
"net/http"
)
type nonceKey struct{}
// Nonce returns the CSP nonce for this request, for use in templates.
func Nonce(ctx context.Context) string {
n, _ := ctx.Value(nonceKey{}).(string)
return n
}
// Middleware issues a fresh nonce and a strict policy on every response.
func Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var b [18]byte // 144 bits -> 24 base64 characters, no padding
if _, err := rand.Read(b[:]); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
nonce := base64.StdEncoding.EncodeToString(b[:])
h := w.Header()
h.Set("Content-Security-Policy",
"script-src 'nonce-"+nonce+"' 'strict-dynamic' 'unsafe-inline' https:; "+
"object-src 'none'; base-uri 'none'; frame-ancestors 'none'; "+
"report-uri /csp-report; report-to csp")
h.Set("Reporting-Endpoints", `csp="https://app.example.com/csp-report"`)
// A nonce in a shared cache is a nonce served to everyone.
h.Set("Cache-Control", "private, no-store")
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), nonceKey{}, nonce)))
})
}
And in an html/template:
<script nonce="{{ .Nonce }}" src="/static/app.js"></script>
The odd-looking 'unsafe-inline' https: in the policy is not a mistake; Part 5 explains why it is safe. The two report directives are covered in Part 9.
Part 4: Hashes — the nonce for pages you cannot render per request
Nonces require a server that renders each response. A static site — Hugo, Astro, a prebuilt single-page app on object storage — serves the same bytes to everyone and cannot insert a fresh value. MDN puts the trade-off plainly: nonce-based directives are easier to maintain if you can generate responses dynamically; otherwise, you need to use hash-based directives6.
A hash-based policy lists the SHA-256 (or SHA-384/512) digest of each inline script the page is allowed to run:
Content-Security-Policy: script-src 'sha256-DGQU7RkNdD9lRuC9apGZW4E+FFzWfVtXbJsWYPxGWis=' 'strict-dynamic'; object-src 'none'; base-uri 'none'
The browser hashes the text of each inline <script> and runs it only if the digest is in the list. An injected script has a different digest. Web.dev recommends this form specifically for HTML served statically or pages that need to be cached7.
The hash is over the exact script text. One added space, one changed quote, a minifier upgrade that reorders something — and the digest no longer matches, the script is blocked, and whatever it did (setting the colour theme before first paint is the classic example) silently stops happening. That makes hash-based CSP a build artefact, not configuration. It must be computed from the built output and checked in CI, or it will drift.
Static site generators can do the first half. Hugo’s resources.Fingerprint computes a digest (SHA-256 by default) and exposes it as .Data.Integrity in Subresource Integrity form — the algorithm name, a hyphen, and the base64 digest10. That is exactly the body of a CSP hash-source:
{{- $init := resources.Get "js/theme-init.js" | resources.Fingerprint "sha256" -}}
<script>{{ $init.Content | safeJS }}</script>
<meta name="x-csp-hash-theme-init" content="{{ $init.Data.Integrity }}">
The second half is a CI job that extracts every hash from the built HTML and fails the pipeline if the deployed policy is missing one. If your generator cannot emit the digests for you, compute them from the output directly:
#!/usr/bin/env python3
"""Print CSP hash-sources for every inline <script> that CSP will check."""
import base64
import hashlib
import sys
from html.parser import HTMLParser
# Types that reach the CSP inline check. Data blocks (application/ld+json,
# text/template, ...) return before it, so they need no hash.
CHECKED = {"", "module", "importmap", "text/javascript", "application/javascript"}
class InlineScripts(HTMLParser):
def __init__(self):
super().__init__(convert_charrefs=False)
self.capturing = False
self.buf = []
self.found = []
def handle_starttag(self, tag, attrs):
if tag != "script":
return
a = dict(attrs)
kind = (a.get("type") or "").strip().lower()
self.capturing = "src" not in a and kind in CHECKED
self.buf = []
def handle_data(self, data):
if self.capturing:
self.buf.append(data)
def handle_endtag(self, tag):
if tag == "script" and self.capturing:
self.found.append("".join(self.buf))
self.capturing = False
def hash_source(text):
# Browsers normalize CR and CRLF to LF before the script text exists.
text = text.replace("\r\n", "\n").replace("\r", "\n")
digest = hashlib.sha256(text.encode("utf-8")).digest()
return "'sha256-" + base64.b64encode(digest).decode("ascii") + "'"
sources = set()
for path in sys.argv[1:]:
parser = InlineScripts()
with open(path, encoding="utf-8", newline="") as f:
parser.feed(f.read())
parser.close()
sources.update(hash_source(t) for t in parser.found)
print(" ".join(sorted(sources)))
Run it over public/**/*.html after the build, diff the output against the policy, and fail on any difference. (If you use legacy type strings such as text/ecmascript, add them to CHECKED.)
The comment about data blocks deserves a sentence, because it is a common source of unnecessary work. In the HTML Standard’s algorithm for preparing a script element, an element whose type is not a JavaScript MIME type, module, or importmap is a data block: the algorithm returns at that step — “No script is executed” — well before the later step that asks whether inline behaviour should be blocked by Content Security Policy9. A JSON-LD <script type="application/ld+json"> never reaches the CSP check, so hashing it only creates one more digest that can drift. An import map, by contrast, does reach the check and does need a hash or nonce.
Three further details matter on hash-based pages:
External scripts. With 'strict-dynamic' present, 'self' and host sources are ignored (Part 5), so how does a static page load /app.js? Either a hashed inline loader creates the script element (trust propagates), or you use CSP Level 3’s provision that a hash-source may match an external script whose <script> element carries matching integrity metadata4. That second path is now broadly supported — Chrome 59, Firefox 116, Safari 15.66 — and MDN notes that every valid hash in the integrity attribute must be present in script-src for the script to load.
Event handlers. Hashes apply to inline <script> elements, not to onclick="..." attributes. CSP Level 3 adds 'unsafe-hashes', which lets event handlers, style attributes, and javascript: navigation targets match hashes4. Treat it as a migration aid: the correct end state is addEventListener.
The <meta> fallback. A static host that cannot set headers can deliver a policy in <meta http-equiv="Content-Security-Policy">, but CSP Level 3 §3.3 states that report-only policies are not supported there, and neither are report-uri, frame-ancestors, or sandbox4 — and, per Part 3, header delivery is what triggers nonce hiding. Use the header whenever you control anything in front of the files.
Part 5: 'strict-dynamic', precisely
A pure nonce or hash policy has a practical problem: modern applications load scripts that load scripts. A tag manager injects a vendor snippet; a framework lazy-loads route bundles; a payment SDK pulls in its own dependencies. None of those dynamically created <script> elements carry your nonce, and you cannot hash code that does not exist at build time.
'strict-dynamic' solves this by propagating trust. CSP Level 3 defines it as allowing script that executes on a page to load more script via non-parser-inserted script elements4. In practice: if a nonced or hashed script calls document.createElement("script") and appends it, the new script runs. What does not inherit trust is a script inserted through the HTML parser — markup written with document.write, for example — because that path is precisely how injected HTML arrives.
The second half of the definition is equally important: when 'strict-dynamic' is present, host-source and scheme-source expressions, 'self', and 'unsafe-inline' are ignored4. That is what makes this backwards-compatible policy safe:
Content-Security-Policy: script-src 'nonce-{RANDOM}' 'strict-dynamic' 'unsafe-inline' https:; object-src 'none'; base-uri 'none'
MDN spells out how each browser generation reads it: a CSP1 browser sees 'unsafe-inline' https:, a CSP2 browser sees https: 'nonce-…' (a nonce present makes it ignore 'unsafe-inline'), and a CSP3 browser sees 'nonce-…' 'strict-dynamic'6. Web.dev’s framing is the same: every browser that supports 'strict-dynamic' ignores the https: and 'unsafe-inline' fallbacks, so they do not weaken the policy7. They exist only so that a very old browser degrades to “no protection” rather than “broken page.”
The trade is explicit. 'strict-dynamic' makes CSP deployable on real applications by trusting whatever your trusted scripts decide to load. That means the security of the page now rests partly on what those scripts do with data. Hold that thought; it is the subject of Part 7.
Part 6: The rest of a strict policy
The core is script-src. A handful of other directives close routes around it.
object-src 'none'. Plugin content (<object>, <embed>) historically executed script outside script-src’s view. Nothing modern needs it. Both the nonce and hash templates from web.dev set it to 'none'7.
base-uri 'none' (or 'self'). An injected <base href="https://attacker.example/"> changes the resolution of every relative script URL on the page, including the nonced <script nonce="…" src="/static/app.js"> you wrote yourself. Web.dev’s templates set base-uri 'none' for exactly this reason — to prevent attackers “changing the locations of scripts loaded from relative URLs”7.
frame-ancestors 'none' (or an explicit list). Clickjacking defence. Header-only, per §3.3.
script-src-elem and script-src-attr. CSP Level 3 splits script control: script-src-elem governs <script> elements, script-src-attr governs event-handler attributes4. Usually you set script-src and let both inherit; the split is useful when you need to relax one without the other.
'wasm-unsafe-eval'. If you run WebAssembly, you need this narrower keyword instead of 'unsafe-eval', which also re-enables JavaScript eval. Supported in Chrome 97, Firefox 102, Safari 166.
Multiple policies intersect. A response can carry more than one Content-Security-Policy header. CSP Level 3 §8.1 is clear that each policy is enforced independently, so adding a policy can only further restrict what the page may do4. This is useful operationally: an edge proxy can add a site-wide frame-ancestors/object-src policy while the application sets its own nonce-based script-src, and neither can loosen the other.
Put together, a strict policy for a server-rendered application looks like this:
Content-Security-Policy:
script-src 'nonce-{RANDOM}' 'strict-dynamic' 'unsafe-inline' https:;
object-src 'none';
base-uri 'none';
frame-ancestors 'none';
report-uri https://app.example.com/csp-report;
report-to csp
Notice what is not there: no default-src, no image or font or style lists. Those directives have real uses, but they are not XSS mitigations, and bundling them into the first rollout is how CSP projects stall. Ship the part that stops script execution first.
Part 7: What a strict policy cannot stop
A strict policy answers “can injected markup become a new script?” It does not answer “can injected data make an existing, trusted script do something dangerous?” There are two large gaps.
Script gadgets
Lekies, Kotowicz, Groß, Vela Nava, and Johns named the first gap at ACM CCS 2017. A script gadget is a legitimate fragment of JavaScript in the application’s own code base that picks up injected, non-script content and transforms it into executable code. Because the injection carries no directly executable script, mitigations that look for script tags and event handlers ignore it — and the paper’s claim is that this class of attack circumvents all XSS mitigation techniques existing at the time, CSP included11. The authors examined 16 popular JavaScript frameworks and libraries for such gadgets.
The canonical example is any library that scans the DOM for attributes like data-bind, data-template, or a framework expression syntax and evaluates what it finds. Under a nonce-based policy, that library is trusted — it carries your nonce — so it runs, and so does what it evaluates. 'strict-dynamic' sharpens this: a gadget that reads a URL out of a data-src attribute and creates a <script> from it now produces a trusted script.
The mitigations are unglamorous: avoid libraries that evaluate markup-embedded expressions, keep 'unsafe-eval' out of your policy (many expression evaluators need it), and prefer frameworks that compile templates at build time.
DOM XSS
The second gap is the one Trusted Types exists for. Consider:
const params = new URLSearchParams(location.search);
document.querySelector("#greeting").innerHTML = `Hello, ${params.get("name")}`;
This is XSS with no server involvement at all. The vulnerable line lives in your trusted, nonced bundle. The payload never passes through your templates. innerHTML will parse <img src=x onerror=…>; a strict policy blocks that particular inline handler, but innerHTML is one of dozens of sinks, and some of them — assigning a URL to script.src, calling eval, passing a string to setTimeout — are exactly the operations a trusted script is allowed to perform. With single-page applications doing most rendering in the browser, this is where modern XSS lives.
CSP decides which scripts may run. It has no opinion on what those scripts pass to the DOM. For that, you need a different mechanism.
Part 8: Trusted Types — make the dangerous sinks refuse strings
Trusted Types changes the DOM’s injection sinks so that, under enforcement, they refuse plain strings and accept only typed objects created by a named policy. The specification — a W3C Working Draft dated 23 June 2026, edited by Krzysztof Kotowicz — describes it as an API that allows applications “to lock down powerful APIs to only accept non-spoofable, typed values in place of strings”12.
There are three types, mapped to sink families13:
| Type | Example sinks |
|---|---|
TrustedHTML | innerHTML, outerHTML, insertAdjacentHTML, document.write() |
TrustedScript | eval(), string arguments to setTimeout()/setInterval(), the Function constructor |
TrustedScriptURL | HTMLScriptElement.src, new Worker(), ServiceWorkerContainer.register() |
Enforcement is two CSP directives. require-trusted-types-for 'script' makes the sinks throw a TypeError when given a string. trusted-types lists the policy names the application may create; createPolicy() with an unlisted name throws, 'none' forbids all policies, and 'allow-duplicates' permits re-creating a name13.
Content-Security-Policy: require-trusted-types-for 'script'; trusted-types app-html app-script-url
Now the vulnerable line from Part 7 throws instead of executing. To assign HTML, code must go through a policy:
// DOMPurify returns a sanitized string; the policy wraps it as TrustedHTML.
const htmlPolicy = globalThis.trustedTypes
? trustedTypes.createPolicy("app-html", {
createHTML: (dirty) => DOMPurify.sanitize(dirty),
})
: { createHTML: (dirty) => DOMPurify.sanitize(dirty) }; // pre-Trusted Types browsers
document.querySelector("#greeting").innerHTML =
htmlPolicy.createHTML(`Hello, ${params.get("name")}`);
// Only scripts from origins we name can become TrustedScriptURL.
const SCRIPT_ORIGINS = new Set([location.origin, "https://widgets.example.net"]);
const scriptUrlPolicy = globalThis.trustedTypes?.createPolicy("app-script-url", {
createScriptURL(url) {
const u = new URL(url, location.href);
if (!SCRIPT_ORIGINS.has(u.origin)) {
throw new TypeError(`script origin not allowed: ${u.origin}`);
}
return u.href;
},
});
The value of this is not the sanitizer — you could call DOMPurify without Trusted Types. The value is where the security review happens. Kotowicz’s web.dev article puts it exactly: no matter how complex the application, “the only thing that can introduce a DOM XSS vulnerability is the code in one of your policies”14. A code base with 400 innerHTML assignments becomes a code base with two policy functions to audit, and the browser enforces that nobody bypassed them.
It also closes the 'strict-dynamic' gap from Part 5. A gadget that turns attacker data into script.src now has to go through app-script-url, which rejects origins you did not name. CSP controls which scripts start; Trusted Types controls what they can do to the page. The two are designed to be deployed together.
The default policy. A policy named default is invoked automatically whenever a string reaches a sink, so legacy code keeps working while you log what it does. MDN recommends it only during transition, and web.dev advises using it sparingly and refactoring toward named policies13 14. A default policy that returns its input unchanged is Trusted Types turned off with extra steps.
Frameworks. You rarely write most of this by hand. Wang, Guðmundsson, and Kotowicz documented integrating Trusted Types into a well-established production web framework at IEEE EuroS&P Workshops 2021, framing the key adoption question as how much refactoring existing applications need — and arguing that the mechanism steers developers toward writing XSS-free code in the first place rather than filtering it afterwards15.
Support. For five years this section needed a caveat, because Trusted Types was Chromium-only (Chrome and Edge 83, 2020). That caveat is gone. Safari shipped it in version 26 in September 2025, and Firefox in version 148 on 24 February 2026, which made it Baseline “newly available” on that date2. Enforcement still degrades gracefully: a browser that does not understand require-trusted-types-for ignores it, and the feature-detection above keeps the code path identical.
Part 9: Rolling it out without breaking production
Most CSP projects that fail do so the same way: someone shipped an enforcing policy, a checkout button stopped working, and the policy was reverted and never tried again. The platform gives you a way to avoid this.
Step 1: Report-only. Content-Security-Policy-Report-Only takes the same syntax but blocks nothing; it only reports. If both headers are present, the enforced policy is enforced and the report-only policy just generates reports6. That lets you run today’s policy and tomorrow’s side by side. Report-only cannot be delivered by <meta>4.
Step 2: Collect reports — in both formats. CSP Level 3 deprecates report-uri in favour of report-to, which uses the Reporting API4. You name an endpoint in a Reporting-Endpoints header and reference it from the policy:
Reporting-Endpoints: csp="https://app.example.com/csp-report"
Content-Security-Policy-Report-Only: script-src 'nonce-{RANDOM}' 'strict-dynamic'; object-src 'none'; base-uri 'none'; report-uri https://app.example.com/csp-report; report-to csp
Browsers that support report-to ignore report-uri, and MDN recommends sending both until support is broad16. That advice is newer than it looks: Chrome has supported report-to since version 70 and Safari since 16.4, but Firefox only added it in version 1496. Any population of older Firefox users will keep sending the legacy format.
The two formats differ. Legacy report-uri reports arrive as Content-Type: application/csp-report with a single object under a csp-report key and hyphenated field names (document-uri, blocked-uri, effective-directive, script-sample)17. Reporting API deliveries arrive as application/reports+json, with report objects of type: "csp-violation" and camel-cased body fields (documentURL, blockedURL, effectiveDirective, sample)16. One endpoint can normalize both:
package csp
import (
"encoding/json"
"io"
"mime"
"net/http"
)
type Violation struct {
Document, Blocked, Directive, Disposition, Sample string
}
type legacyReport struct {
R struct {
DocumentURI string `json:"document-uri"`
BlockedURI string `json:"blocked-uri"`
EffectiveDirective string `json:"effective-directive"`
Disposition string `json:"disposition"`
ScriptSample string `json:"script-sample"`
} `json:"csp-report"`
}
type apiReport struct {
Type string `json:"type"`
Body struct {
DocumentURL string `json:"documentURL"`
BlockedURL string `json:"blockedURL"`
EffectiveDirective string `json:"effectiveDirective"`
Disposition string `json:"disposition"`
Sample string `json:"sample"`
} `json:"body"`
}
// ReportHandler accepts both report-uri and report-to deliveries.
func ReportHandler(sink func(Violation)) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 64<<10))
if err != nil {
w.WriteHeader(http.StatusRequestEntityTooLarge)
return
}
mediaType, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type"))
switch mediaType {
case "application/csp-report":
var rep legacyReport
if json.Unmarshal(body, &rep) == nil {
sink(Violation{rep.R.DocumentURI, rep.R.BlockedURI,
rep.R.EffectiveDirective, rep.R.Disposition, rep.R.ScriptSample})
}
case "application/reports+json":
var reps []apiReport
if json.Unmarshal(body, &reps) == nil {
for _, rep := range reps {
if rep.Type == "csp-violation" {
sink(Violation{rep.Body.DocumentURL, rep.Body.BlockedURL,
rep.Body.EffectiveDirective, rep.Body.Disposition, rep.Body.Sample})
}
}
}
}
w.WriteHeader(http.StatusNoContent)
})
}
Two operational notes. First, treat this endpoint as hostile input: it is unauthenticated by necessity, anyone can post to it, and the fields contain attacker-influenced URLs and code samples. Cap the body (as above), rate-limit it, and never render the samples unescaped in a dashboard. Second, add 'report-sample' to script-src during rollout; CSP Level 3 then includes the first 40 characters of the blocked inline code in the report4, which is usually enough to tell your own forgotten analytics snippet from an attack — and from the browser extensions that will generate a steady background of reports you cannot fix.
Step 3: Trusted Types in report-only. The same pattern works for sinks. Web.dev’s recommended starting point is Content-Security-Policy-Report-Only: require-trusted-types-for 'script', with reports collected through the same endpoint, filtering on effectiveDirective of require-trusted-types-for14. The report volume is a direct inventory of the string-to-sink assignments you need to refactor.
Step 4: Enforce, and keep report-only running for the next change. Once the reports are quiet except for extensions, switch the header name. Then keep a Report-Only header permanently for the next tightening.
Part 10: If you take card payments, this is no longer optional
For most sites, strict CSP is good engineering. For pages that take payment cards, it is now close to a compliance mechanism.
PCI DSS v4.0 introduced two requirements aimed at e-skimming — attacks that inject or modify scripts on checkout pages to steal card data. Requirement 6.4.3 covers payment page scripts being authorized, integrity-checked, and inventoried; Requirement 11.6.1 requires a change- and tamper-detection mechanism covering payment pages and the security-impacting HTTP headers they receive. Both were among the future-dated requirements that became effective on 31 March 2025, and on 10 March 2025 the PCI Security Standards Council published an information supplement, Payment Page Security and Preventing E-Skimming, to guide implementation18.
The requirements are technology-neutral — they do not mandate CSP, and the Council’s announcement of the supplement does not mention it. But CSP is the browser’s native answer to “only these scripts may run on this page”: a nonce- or hash-based policy with integrity-matched external scripts is, very directly, an enforced script authorization list, and violation reports are a tamper signal. And among the security-impacting headers a checkout page receives, the CSP is the one that decides what scripts run.
One scoping note. Merchants who fully outsource the payment page and validate with the shorter SAQ A had 6.4.3 and 11.6.1 removed from that questionnaire, effective the same date — replaced by an eligibility criterion that the merchant confirm their site “is not susceptible to attacks from scripts that could affect the merchant’s e-commerce system(s)”19. A strict policy on the page that embeds or redirects to the payment form is one of the more defensible ways to be able to confirm that.
The current CSP Editor’s Draft also points somewhere interesting for this use case: a report-sha256 (and -384/-512) source expression that generates hash reports for script-like subresources, carrying the document URL, subresource URL, and hash4. That is, in effect, a browser-native script inventory feed. Treat it as a draft to watch, not something to build compliance on yet.
Part 11: What to do on Monday
- Evaluate the policy you already have against the only question that matters: if an attacker injects HTML, can they run script? If your
script-srclists hosts, the answer is probably yes. Google’s CSP Evaluator, built on the 2016 research, helps find the subtle bypasses that undermine a policy20. - Server-rendered applications: move to nonces. One middleware, 128+ bits per response,
'strict-dynamic', the'unsafe-inline' https:fallback,object-src 'none',base-uri 'none'. Make nonced pages uncacheable by shared caches. - Static sites: move to hashes, and make them a build artefact. Compute digests from the built output, check them in CI, fail on drift. Do not hash data blocks. Load external bundles through a hashed loader or
integrity-matched hashes. - Deploy in report-only first, with both
report-uriandreport-to,'report-sample'on, and an endpoint that treats reports as hostile input. - Turn on Trusted Types in report-only in the same sprint. It is Baseline now. The report stream is your refactoring backlog; the end state is a couple of named policies and no
defaultpolicy. - Audit your libraries for gadgets. Anything that evaluates expressions found in markup is a bypass you have allowlisted by nonce. Keep
'unsafe-eval'out of the policy. - If a page touches card data, map this work to PCI DSS 6.4.3 and 11.6.1 explicitly, so the controls you are building anyway get counted.
Closing
For its first six years, Content Security Policy was mostly theatre. Not because anyone deployed it carelessly, but because its central abstraction was wrong: a domain is not a unit of trust. A CDN is an archive of every version of everything. An API host is a JSONP endpoint waiting to be found. A domain in an allowlist is a domain someone will one day let expire. The 2016 measurement that found almost no deployed policy doing its job was not an indictment of developers; it was an indictment of the model.
The model that replaced it is better because it is narrower. A nonce says “this script, in this response.” A hash says “these exact bytes.” 'strict-dynamic' says “and whatever those choose to load” — which is a real trust decision, and an honest one. Trusted Types then asks the question CSP never could: once a trusted script is running, what is it allowed to do with a string?
That combination is finally deployable everywhere. The last engine shipped Trusted Types in February. The reporting pipeline works in every major browser. The research has been done, the templates are published, and the rollout path through report-only mode means nothing has to break.
What remains is the part no specification can do for you: deciding that a policy exists to stop script execution, and measuring it against that and nothing else. The engineering lead in the opening was right that she had a policy. The question she needed to ask was not whether the header was present. It was whether the tester’s two lines of markup would run. With a strict policy and Trusted Types, they don’t.
References
Weichselbaum, L., Spagnuolo, M., Lekies, S., and Janc, A., “CSP Is Dead, Long Live CSP! On the Insecurity of Whitelists and the Future of Content Security Policy”, Proceedings of the 23rd ACM Conference on Computer and Communications Security (CCS), Vienna, 2016. https://research.google/pubs/csp-is-dead-long-live-csp-on-the-insecurity-of-whitelists-and-the-future-of-content-security-policy/ ↩︎ ↩︎ ↩︎
W3C WebDX Community Group, “Trusted Types”, web-features explorer (Baseline newly available since 2026-02-24; Chrome/Edge 83, Safari 26, Firefox 148). https://web-platform-dx.github.io/web-features-explorer/features/trusted-types/ ↩︎ ↩︎
Roth, S., Barron, T., Calzavara, S., Nikiforakis, N., and Stock, B., “Complex Security Policy? A Longitudinal Analysis of Deployed Content Security Policies”, Network and Distributed System Security Symposium (NDSS), 2020. https://www.ndss-symposium.org/ndss-paper/complex-security-policy-a-longitudinal-analysis-of-deployed-content-security-policies/ ↩︎ ↩︎
West, M. and Sartori, A. (eds.), “Content Security Policy Level 3”, W3C Working Draft, 13 August 2026. https://www.w3.org/TR/CSP3/ — Editor’s Draft: https://w3c.github.io/webappsec-csp/ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
MITRE, “2025 CWE Top 25 Most Dangerous Software Weaknesses”. https://cwe.mitre.org/top25/archive/2025/2025_cwe_top25.html ↩︎
MDN Web Docs, “Content Security Policy (CSP)” guide and
script-srcreference, with browser compatibility data from mdn/browser-compat-data. https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP ; https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/script-src ; https://github.com/mdn/browser-compat-data/blob/main/http/headers/Content-Security-Policy.json ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎Weichselbaum, L., “Mitigate cross-site scripting (XSS) with a strict Content Security Policy (CSP)”, web.dev. https://web.dev/articles/strict-csp ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
Golinelli, M., Bonomi, F., and Crispo, B., “The Nonce-nce of Web Security: an Investigation of CSP Nonces Reuse”, WASP workshop at ESORICS 2023; arXiv:2309.07782. https://arxiv.org/abs/2309.07782 ↩︎
WHATWG, “HTML Living Standard” — “The script element” (prepare the script element) and “Nonce attributes”. https://html.spec.whatwg.org/multipage/scripting.html ; https://html.spec.whatwg.org/multipage/urls-and-fetching.html ↩︎ ↩︎
Hugo documentation, “resources.Fingerprint”. https://gohugo.io/functions/resources/fingerprint/ ↩︎
Lekies, S., Kotowicz, K., Groß, S., Vela Nava, E., and Johns, M., “Code-Reuse Attacks for the Web: Breaking Cross-Site Scripting Mitigations via Script Gadgets”, ACM CCS 2017. https://research.google/pubs/code-reuse-attacks-for-the-web-breaking-cross-site-scripting-mitigations-via-script-gadgets/ ↩︎
Kotowicz, K. (ed.), “Trusted Types”, W3C Working Draft, 23 June 2026. https://www.w3.org/TR/trusted-types/ ↩︎
MDN Web Docs, “Trusted Types API” and “CSP: trusted-types”. https://developer.mozilla.org/en-US/docs/Web/API/Trusted_Types_API ; https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/trusted-types ↩︎ ↩︎ ↩︎
Kotowicz, K., “Prevent DOM-based cross-site scripting vulnerabilities with Trusted Types”, web.dev. https://web.dev/articles/trusted-types ↩︎ ↩︎ ↩︎
Wang, P., Guðmundsson, B. Á., and Kotowicz, K., “Adopting Trusted Types in Production Web Frameworks to Prevent DOM-Based Cross-Site Scripting: A Case Study”, 2021 IEEE European Symposium on Security and Privacy Workshops (EuroS&PW). https://research.google/pubs/adopting-trusted-types-in-production-web-frameworks-to-prevent-dom-based-cross-site-scripting-a-case-study/ ↩︎
MDN Web Docs, “CSP: report-to”. https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/report-to ↩︎ ↩︎
MDN Web Docs, “CSP: report-uri”. https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/report-uri ↩︎
PCI Security Standards Council, “New Information Supplement: Payment Page Security and Preventing E-Skimming”, 10 March 2025. https://blog.pcisecuritystandards.org/new-information-supplement-payment-page-security-and-preventing-e-skimming ↩︎
PCI Security Standards Council, “Important Updates Announced for Merchants Validating to Self-Assessment Questionnaire A”, 30 January 2025. https://blog.pcisecuritystandards.org/important-updates-announced-for-merchants-validating-to-self-assessment-questionnaire-a ↩︎
Google, “CSP Evaluator”. https://csp-evaluator.withgoogle.com/ ↩︎