Notes · Dissecting Real Systems
growing
Trust No Script
Why a strict Content Security Policy is one of the hardest headers to deploy — and how to read one with Google's CSP Evaluator.
Writing a secure web application starts at the architecture phase. A vulnerability discovered in this phase can cost as much as 60 times less than a vulnerability found in production code.
— Andrew Hoffman, Web Application Security (2020)
Cite this
Mangalapilly, Y. J. (2026, July). Trust No Script. Saṃhitā Notes. https://yesudeep.com/blog/trust-no-script/ @online{mangalapilly2026trust,
author = {Yesudeep Jose Mangalapilly},
title = {Trust No Script},
journal = {Sa\d{m}hit\=a Notes},
year = {2026},
month = {July},
url = {https://yesudeep.com/blog/trust-no-script/},
urldate = {2026-08-12},
} Yesudeep Jose Mangalapilly. “Trust No Script.” Saṃhitā Notes, 2026. https://yesudeep.com/blog/trust-no-script/. TY - ELEC
AU - Mangalapilly, Yesudeep Jose
TI - Trust No Script
T2 - Saṃhitā Notes
PY - 2026
UR - https://yesudeep.com/blog/trust-no-script/
Y2 - 2026-08-12
ER - An anatomy of one of the hardest security headers to deploy correctly. By the end you'll understand what CSP defends against, why the obvious approach — an allowlist of trusted domains — was shown to be almost always bypassable, how the nonce-hash-and-strict-dynamic design fixes it, why AI-assisted attack synthesis makes that execution boundary more important without making it sufficient, how to read a strict policy with Google's CSP Evaluator, and why — despite all this — almost no one on the web has it.
New to CSP? Start with Joe Medley's Content security policy on web.dev, which explains the mechanism from first principles; the strict-CSP guidance this piece argues with is Lukas Weichselbaum's Adopting a strict CSP. Technical writers are the most reliably uncredited people in this field, which is a good reason to name them.
A strict CSP is finicky to deploy — not because the idea is complex, but because doing it correctly fights both the history of the web and the way real pages are built. It also fights your own team. There is a constant pull from the business and from developers — ship faster, keep the inline script, paste in the third-party tag, don't slow the build — and a strict CSP is unforgiving about exactly those conveniences. Hoffman's architecture-phase point is the lever you push back with: the cost of getting this right is lowest at the architecture phase, and rises steeply the longer the convenient-but-unsafe pattern is allowed to set. The article explains why — the technical why, and a little of the organizational one.
On 2026-08-12, this website deployed a hash-based strict CSP as well. You can check the following reports for details:
A web page is a program the browser runs on your behalf, and by default it runs whatever the page contains — every inline <script>, every file pulled from every domain. That default is the entire attack surface of cross-site scripting: get one <script> of your choosing into the page, and the browser executes it with the victim's full authority. A Content Security Policy is the header that takes the default away. In MDN's words, it controls "which resources… a document is allowed to load," as "a defense against cross-site scripting (XSS) attacks."
Cross-site scripting (XSS) is an attack that injects the attacker's own JavaScript to run inside your page — via a comment field that echoes back a <script> tag, a search box that reflects its query into the HTML unescaped, anywhere attacker-controlled text becomes page code. Once it runs it is your page: it can read any cookie or token JavaScript can reach (HttpOnly cookies stay hidden) and fire authenticated requests as the signed-in user. It has been among the most common web vulnerabilities for two decades. Learn more.
A web page runs whatever it contains. CSP is the one header that changes that default to "run only what I vouch for."
Think of a school playground with one guard at the gate. The old rule was "any kid wearing our school's uniform gets in" — but uniforms can be bought, borrowed, or faked, so the wrong kids slip through. The new rule is a wristband: every morning the school hands out fresh bands in a color nobody could guess in advance, and the guard admits only the kids wearing today's band. A stranger at the gate has no way to be wearing it. That wristband is a nonce — a one-time token the page hands to the scripts it trusts, so the browser, like the guard, runs only the ones carrying it.
The same idea runs deeper in computing than the web. An operating system, by default, will load any kernel driver it's handed — and a driver runs in ring 0, with total control of the machine, the way an injected script runs with the page's full authority. So OSes stopped trusting where a driver came from and started demanding a cryptographic signature. A strict CSP is that move one layer up: stop trusting where a script came from; run it only if it carries a token you issued.
The parallel is worth holding onto, because it tells you what kind of defense CSP is — and which CSP. It's the strict CSP that mirrors the signed driver: one that vouches for each script by an unforgeable token (a nonce) or by the script's own fingerprint (a hash), exactly as a driver signature vouches for each driver. A driver signature and a script hash are the same move — a cryptographic proof of "this exact code, vouched for"; a nonce is the token variant of it. (The older allowlist CSP, which the next section examines, is not this at all — it's closer to letting any driver from a trusted vendor load, which is precisely why it failed.) Signed drivers don't make a driver safe — a signed driver can still be buggy or malicious; the signature only proves it's the code someone vouched for, not code an attacker slipped in. A strict CSP buys the same guarantee for scripts, and no more: it doesn't sanitize what your scripts do, it just ensures the only scripts that run are the ones you authorized. That's exactly the right tool against XSS, whose whole premise is unauthorized code reaching the page.
The idea, and the first version that didn't work
The first instinct is the obvious one: list the places scripts are allowed to come from. That's an allowlist CSP, and CSP Level 1 (2012) was built on it — script-src naming the trusted hosts, the browser refusing everything else. It looks like this:
Content-Security-Policy:
script-src 'self' https://apis.google.com https://cdn.example;
object-src 'none';
A CSP1 allowlist policy. Trusted hostnames are allowed to provide scripts.
A script from apis.google.com runs; the same script served from evil.example is blocked. Reasonable-looking — and, as the next section shows, almost useless.
Think of the browser as a venue letting people on stage. The allowlist approach is a guest list of companies: "anyone from Google, anyone from this CDN, anyone from that analytics vendor can perform." It sounds safe until you realize some of those companies will, on request, put whatever you wrote on stage under their name.
In 2016 a team at Google measured how well this worked across more than 1.6 million sites, and the answer was: almost not at all. The paper's title is "CSP Is Dead, Long Live CSP!", and its finding is brutal — 94.72% of all distinct policies were trivially bypassable, and *99.34% of hosts with a CSP used policies that offered no benefit against XSS at all.*
An allowlist of domains you trust is an allowlist of everything those domains will host for an attacker — which is almost everything.
The mechanism is a gadget: a script on an allowlisted domain that an attacker can repurpose. The clearest example is a JSONP endpoint, which wraps its JSON response in a caller-named function so the browser can run it as a script. The caller picks the name — and the endpoint echoes it back verbatim:
GET https://trusted-cdn.example/api?callback=alert(document.cookie)//
→ alert(document.cookie)//({"data": …})
A trusted-host gadget. The callback parameter turns data into attacker-chosen script.
That response is served from trusted-cdn.example, so script-src trusted-cdn.example waves it straight through — and the attacker has just run alert(document.cookie) on your origin. The // comments out the rest.
An old AngularJS copy on an allowlisted host is the same story by another route: feed it the right markup and it turns benign-looking HTML into code. The study found 14 of the 15 most-commonly-allowlisted domains hosted a gadget like this. Allowlist your analytics vendor and you've very likely allowlisted a way to run anything. The allowlist isn't merely hard to maintain — it's insecure by construction.
The fix: vouch for scripts, don't trust their address
The repair inverts the question. Stop asking where a script came from. Start asking did I, the server, vouch for this exact script? The mechanics predate the paper: nonces and hashes were already standardized in CSP Level 2 (Candidate Recommendation, July 2015) and shipping in browsers — the paper itself notes the approach was "already defined by the CSP specification and available in major browser implementations." What the paper contributed was the measurement, and one new keyword: it proposed 'strict-dynamic', which CSP Level 3 adopted.
Nonce — "number used once." Per the CSP3 spec, the server "MUST generate a unique value each time it transmits a policy" — and should make that value random bits from a secure generator. Predict it and the protection is gone. The nonce checklist in this section gives the full anatomy. Learn more.
Two mechanisms do the vouching. A nonce is a random token the server generates fresh for every single response and stamps on both the policy and the script tags it trusts; the browser runs only scripts carrying that response's nonce. A hash (sha256-…) vouches for one specific inline script by the fingerprint of its contents — the same content-hash move that Subresource Integrity makes for an external script. (If a nonce is the playground's daily wristband, a hash is admission by biometrics — not a token you carry but the script's own fingerprint, computed from its exact bytes; change one character and it no longer matches.) Either way, an injected <script> — which the attacker can't nonce, because they don't know this response's random value, and can't hash-match, because its bytes differ — simply doesn't run.
Those two mechanisms give three real-world flavors of strict policy, and which one fits depends on how your HTML is served:
- Nonce-based. A fresh random token per response, threaded into every script tag server-side. The natural fit when the HTML is generated per request (a server-rendered app, a page assembled by middleware) — you already have a render step to stamp the nonce into.
- Hash-based. No token at all: the policy lists a
sha256-of each trusted inline script's bytes. Because a hash is stable, the HTML can be statically built and edge-cached — which is why a static-site generator or a framework-built front-end reaches for hashes. (It's what this site uses; the policy snapshot used for this article is hash-based, for exactly the caching reason a nonce can't survive.) - Hybrid. Both at once — a nonce for the per-response root script plus hashes for the build-time inline ones. The nonce covers what's dynamic; the hashes cover what's baked in.
The rest of this piece works through the nonce mechanics in detail, because the nonce is where the sharp edges live — but everything about vouching, strict-dynamic, and the traps applies to the hash and hybrid forms too.
strict-dynamic: trust that propagates
But nonces alone would mean nonce-stamping every script, including the dozens a third-party widget loads dynamically. CSP3's strict-dynamic closes that gap: trust given to a script by a nonce or hash propagates to the scripts that script loads. From the spec, once strict-dynamic is in play, "host-source and scheme-source expressions, as well as the 'unsafe-inline' and 'self' keyword-sources will be ignored when loading script." The allowlist doesn't just become unnecessary — supporting browsers ignore it entirely.
The propagation has one sharp edge worth knowing before you deploy: it extends only to scripts added through APIs like createElement plus appendChild. The spec's very next rule (§8.2) is that "script requests which are triggered by non-parser-inserted script elements are allowed" — and its own worked example spells out the consequence: a script added via createElement() "is not 'parser-inserted'" and loads, while "document.write() produces script elements which are 'parser-inserted'" and will not load. Old ad tags and loaders that document.write their payloads are the single most common thing strict-dynamic breaks — which is also why the 2009 script-loading tricks on this very site are not just obsolete but un-CSP-able.
Honesty also requires the residual: vouching is not a proof. A year after the allowlist paper, the same research community showed (Lekies et al., CCS 2017) that script gadgets — benign code fragments in popular frameworks that can be coaxed into executing attacker data — bypassed strict-dynamic policies in 13 of 16 frameworks tested: the nonced framework is trusted, and the framework itself relays the attack. A strict CSP raises the bar from "inject any script" to "find a gadget in a library the page already vouched for." A real raise — and less than a guarantee.
Back at the gate. The wristband lets the trusted kid in, and strict-dynamic says whoever that kid brings with them is fine too, no band needed — which is the only workable rule, because the trusted kid arrives with a dozen friends every morning. It works right up until the trusted kid turns out to be the helpful one who will carry anything anyone hands them through the gate. That is a script gadget: nobody forged a band. They used the kid who already had one.
strict-dynamic propagates that trust to the scripts it loads, so no host allowlist is needed. An injected script carries no nonce and is blocked. Old browsers ignore the keyword and fall back to the https: allowlist.Anatomy of a nonce
The whole scheme rests on the attacker being unable to produce the nonce, so the nonce had better be unforgeable. The CSP3 spec is exact about this, and the hierarchy of its requirements is worth reading closely — because only one of them is a hard MUST. From §7.1, "Nonce Reuse":
If a server delivers a
nonce-sourceexpression as part of a policy, the server MUST generate a unique value each time it transmits a policy. The generated value SHOULD be at least 128 bits long (before encoding), and SHOULD be generated via a cryptographically secure random number generator in order to ensure that the value is difficult for an attacker to predict.
Read the verbs carefully. Uniqueness per response is the MUST; the 128 bits and the cryptographically secure generator are *SHOULD*s — strong advice, not conformance requirements. That split is exactly backwards from how it should feel in practice, because the SHOULDs are what actually make the MUST mean anything. A unique-but-guessable nonce satisfies the letter of the spec and provides no protection at all.
So, concretely, a good nonce is:
- Drawn from a cryptographically secure source. Never use
Math.randomor a language PRNG; usecrypto.randomBytes(Node),os.urandomorsecrets(Python), or thegetrandom()system call directly. PRNGs carry internal state an attacker can reconstruct from a few outputs and then roll forward to predict every value it will emit. - At least 128 bits of entropy, then base64-encoded. 128 random bits is
os.urandom(16)about 24 base64 characters, like thet0jzsyT-QmOO4xrJxZpuwA==in this section's worked policy. The browser never decodes it — per the spec's grammar note, a nonce is a strict string match, so the encoding is purely for the server operator's convenience. - Fresh for every single response. This is the MUST, and it's where real deployments quietly fail.
Note
Prefer the getrandom() syscall over reading /dev/urandom directly. On older Linux the device never blocks, so reading it before the kernel's CSPRNG was first seeded — an early-boot risk on low-entropy systems like fresh VMs and containers — could return predictable bytes. getrandom() (Linux 3.17) blocks until seeded; Linux 5.4 added CPU-jitter self-seeding so it can't hang. Python's os.urandom and secrets have used getrandom() since 3.6, and Node's crypto uses the OS CSPRNG — so a nonce from secrets.token_urlsafe() or crypto.randomBytes is safe even on a freshly-booted machine.
A nonce is not a hash. A hash is derived from the script's bytes and is stable across responses; a nonce is random and must change every response. Both vouch — one by fingerprint, one by token. A strict policy can carry both.
That last point is the one that bites. The MUST-be-unique requirement quietly forbids a whole class of common setups:
- Caching the HTML. If a page carrying
nonce-abc123is cached — by a CDN, a reverse proxy, or the browser — and replayed to other users, they all share one nonce. Now it's effectively public: an attacker fetches the page, reads the nonce, and injects a<script nonce"abc123">=. The nonce must vary per response, which means the HTML can't be statically cached the way most sites want to cache it. (The spec doesn't single out CDNs — this just follows directly from the per-response MUST.) - A "nonce" hard-coded in config. A literal
nonce-dGVzdA=checked into a template is a unique string that never changes — so it's a constant, public the moment anyone views source. Scanners like Invicti flag a static nonce as a real finding, because it is one. - Reusing one nonce across requests for performance. Same failure: predict or observe it once, reuse it forever.
The only hard requirement is that a nonce is unique per response. But a nonce that's unique yet guessable — or cached, or hard-coded — is no protection at all. Random, bits, and never reused: that's the part that matters.
The caching conflict is the one that surprises teams most, because so much of the web is statically generated and edge-cached. The escape hatch is to stop nonce-ing entirely for static builds and vouch by hash instead (a hash is derived from the script's bytes, so it's stable and cacheable) — or to keep the nonce but inject it at the edge, per response, with a worker or middleware so the cached HTML is never the thing that carries it. Which of those you reach for, and what Cache-Control you set alongside the policy, is a subject of its own — one most people have never had explained. That trade-off is worked through separately in The Header That Can't Be Cached.
There's a subtler failure the spec also addresses: even a perfect nonce can be stolen from the page before it's used. §7.2, "Nonce Hijacking," covers dangling-markup attacks that exfiltrate a live nonce, and is why the HTML spec hides the nonce from CSS attribute selectors by moving it into an internal slot and blanking the visible attribute. The token has to be unguessable and unreadable.
Reading a strict policy
Here is a worked policy assembled from the CSP3 specification and web.dev's strict-CSP guidance. It is a teaching specimen, not a policy copied from a production site: the nonce and hashes are placeholders, and the resource and reporting endpoints are deliberately generic so the structure stays visible.
default-src 'none';
script-src 'nonce-{RANDOM}' 'strict-dynamic'
'sha256-{HASH}' 'sha256-{HASH}'
'unsafe-inline' https:;
object-src 'none';
base-uri 'none';
frame-ancestors 'none';
require-trusted-types-for 'script';
trusted-types default;
report-uri https://example.com/csp-report;
report-to csp-endpoint;
upgrade-insecure-requests;
A standards-based strict CSP specimen. The nonce and strict-dynamic carry the script trust model; placeholders and omitted values are marked with braces and ellipses.
Both reporting directives, deliberately. report-uri is deprecated — CSP3 says it "is deprecated in favor of the new report-to directive" — but report-to is not yet universal, and browsers that support it ignore report-uri. Sending both is the migration pattern: CSP3 browsers use report-to, old ones still report. report-to names a group configured by a separate Reporting-Endpoints header.
Read it from the top and the design reveals itself.
default-src 'none' is the posture: deny everything by default, then grant back only what's named. You start from zero, not from "the web."
The script-src line is the whole graceful-degradation trick stacked into one directive, and it's worth slowing down on, because it's designed to be read by three different generations of browser at once. A CSP3-capable browser sees the nonce and strict-dynamic and uses only those — ignoring unsafe-inline (because a nonce is present) and https: (because strict-dynamic is). A CSP2 browser doesn't understand strict-dynamic, so it honors the nonce and the hashes. An ancient CSP1 browser understands neither and falls back to unsafe-inline https: — weak, but it keeps the page working. One line, three policies, strongest one wins where it's understood. As Google's own guidance puts it: browsers that support strict-dynamic "ignore the https: fallback, so this won't reduce the strength of the policy."
The script-src line looks self-contradictory — it has both the strict nonce and the loose unsafe-inline https:. It isn't a mistake. Each browser generation reads the subset it understands and ignores the rest; the CSP3 spec guarantees the strong keywords win where they're supported.
Don't take the stacking on faith — judge it. The strict-policy exercise turns that script-src line into a truth table: the chips toggle each source expression in and out, and six script scenarios are re-verdicted live under whichever browser generation you pick.
script-src line, judged live. Toggle the browser generation: under CSP1 everything rides on unsafe-inline and https:; under CSP2 the nonce takes over and neutralizes unsafe-inline; under CSP3, strict-dynamic strikes out https: entirely (toggle it — nothing changes) and document.write flips to blocked while createElement stays trusted.object-src 'none' and base-uri 'none' are the quiet, essential hardening. object-src "disable[s] dangerous plugins"; base-uri "block[s] the injection of <base> tags," which would otherwise let an attacker rewrite where every relatively-named script loads from. Skip these two and a nonce-based policy is still bypassable — which is exactly the kind of subtle hole the CSP Evaluator exists to catch.
require-trusted-types-for 'script' is the deepest line. Nonces stop an attacker from injecting a script tag; Trusted Types stop them from reaching a DOM XSS sink like innerHTML with a string at all — those functions "only accept non-spoofable, typed values created by Trusted Type policies, and reject strings." It closes the injection class that CSP's script rules can't see.
The CSP Evaluator: catching the subtle holes
You don't have to eyeball a policy for these traps. CSP Evaluator — built by the authors of the 2016 research, hosted by Google — exists precisely to "identify subtle CSP bypasses which undermine the value of a policy." Paste a policy in and it grades each directive, flagging the failure modes the research cataloged.
It's a linter for your security header. Paste the policy, and it tells you the things that look safe but aren't: an allowlisted domain that hosts a known bypass gadget, a script-src that forgot you also need object-src 'none', an unsafe-inline that isn't neutralized by a nonce. The same checklist the "CSP Is Dead" paper turned into findings, turned back into a tool.
Run the policy through it and the strict pieces pass: a nonce is present, strict-dynamic is set, object-src and base-uri are locked to 'none'. Configurations even roughly this careful were vanishingly rare in the 2016 study — in its words, "only 9.37% of the policies in our data set have stricter configurations and can potentially protect against XSS" (that is, they merely avoided the trivially fatal keywords; strict-dynamic didn't yet exist in that corpus) — "however, we find that at least 51.05% of such policies are still bypassable." Half of even the careful ones.
What the absence has cost
The demonstrations above are research. The bill below is a matter of public record, and it is worth reading closely because it does not show a strict CSP as a magic wand.
Between 21 August and 5 September 2018, someone modified a JavaScript file served by British Airways so that payment details typed by customers were copied to a server the attacker controlled, on a domain chosen to read as the airline's own. The ICO's penalty notice records 429,612 people affected and a £20m penalty — reduced from an announced £183.39m — and finds separately that card data had been logged in plaintext since 2015, so the script is not the whole story.
The precise part is which half of a policy would have applied. The modified file was first-party: it was already on the page, already allowed, and a nonce vouches for the tag rather than for the file's contents, so script-src would have let it run. What stood between the skimmer and the attacker was a connection to an unfamiliar host — and connect-src is the directive that refuses exactly that, regardless of which script asks.
That is the argument for writing the whole policy rather than the famous half of it. A team that ships script-src and leaves connect-src unset has built the control that would not have applied here and skipped the one that would.
Why almost no one has this
The deployment record is sobering. Strict CSP is well-specified, tooled, and documented — and strict CSP remains vanishingly rare. The measurements are consistent across years and research groups.
First, most sites have no CSP at all. The HTTP Archive Web Almanac tracks adoption climbing only slowly: the 2024 security chapter reports the CSP header on 19% of hosts, up from 15% in 2022, and the 2025 chapter puts it at 21.9% on its own page-based measure. A header that has existed for well over a decade still misses roughly four sites in five.
Second, of the sites that do have a CSP, the strict mechanisms are still a minority — and almost all of them keep the weak unsafe-inline keyword that a strict policy is supposed to neutralize. The 2024 Web Almanac reports the following shares among sites that send any CSP:
unsafe-inline; only a fifth use nonces, a tenth strict-dynamic. The accented bars are the strict mechanisms.The independent NDSS 2020 measurement put the same finding starkly: "insecure practices are present in 90% of policies, whereas secure practices like nonces or hashes reach less than a 5% adoption rate." It also found CSP is increasingly deployed for other purposes entirely — 58% of CSP-using sites used it for something other than restricting scripts.
The mechanism that actually stops XSS exists, is standardized, and is free. A decade on, well under a tenth of the sites that bother with CSP at all have turned it on.
Why the gap? Because a strict CSP fights how real pages are built. Inline event handlers (onclick"…") and =javascript: URLs stop working and must be refactored out — and the spec's own escape hatch for that case is worth knowing precisely, because it is easy to reach for and hard to give back. The nonce has to be freshly generated server-side and threaded through the templating system into every script tag — which is awkward for the statically-served, framework-built front-ends that dominate in static deployments (those reach for hashes instead). Trusted Types asks you to route every DOM-sink call through a typed policy, and any third-party library that touches innerHTML the old way breaks until it's wrapped. The Trusted Types point quietly reshapes which libraries you let developers adopt at all: a dependency that eval=s, injects its own inline scripts, or writes raw =innerHTML is no longer a free choice under a strict CSP. I've drawn that out as a decision tree — Can I Use This Library? — for deciding, per dependency, whether to adopt it, wrap it, sandbox it, or walk away. None of this is conceptually hard. All of it is a great deal of careful, unglamorous work against a codebase that wasn't built expecting it — which is why a correct strict CSP remains, mostly, a thing large security teams do and others admire from a distance.
The escape hatch for inline handlers, and what it costs
"Refactor every onclick into addEventListener" is correct advice and, against a large legacy codebase, sometimes a quarter of work nobody has budgeted. CSP3 anticipates this. A plain hash source does not authorize an event handler — as MDN puts it, policies with hashes "allow scripts and styles by their hash, but not event handlers." The 'unsafe-hashes' source expression changes that: per the CSP3 spec, it "will now allow event handlers, style attributes and javascript: navigation targets to match hashes."
So you can hash the body of the handler and keep the attribute:
<button onclick="myScript()">Submit</button>
Content-Security-Policy: script-src 'unsafe-hashes' 'sha256-{HASH-OF-myScript()}'
Authorizing an inline event handler by hash. The hash covers the attribute's script text — myScript() — not the element it sits on.
The keyword is named unsafe- for a reason, and the reason follows from what a hash can and cannot bind. A hash identifies a string of code, not the place it runs. Once myScript() is authorized by hash under 'unsafe-hashes', that string is authorized anywhere in the document — so an attacker with an HTML injection that cannot introduce new script text can still attach the already-blessed handler to an element of their choosing, at a moment of their choosing. You have not authorized a behavior; you have authorized a fragment, and fragments are portable.
unsafe-. A hash binds a string of code, not the element it runs on — so authorizing a handler's text under 'unsafe-hashes' authorizes that same text anywhere in the document, including on markup an attacker injected.Warning
'unsafe-hashes' is a migration tool, not a destination. It buys a legacy codebase time to move handlers into nonced or hashed scripts; it does not make inline handlers safe, and every hash you add under it is a code fragment the page will execute wherever it appears.
But the deeper reason isn't the codebase — it's the organization around it. Every convenience a strict CSP forbids is something a real team is actively pushing toward: the business wants the feature shipped this sprint, the analytics vendor's snippet pasted during the launch, the marketing tag live before the campaign; developers want the inline handler because it's right there, the eval-ing library because it already works, the build left untouched because it's fast. The instincts are understandable — they are the ordinary, healthy pressure of a team trying to move. The trouble is that script-src 'nonce-…' 'strict-dynamic' is unforgiving about precisely these things: there is no "just this once" inline script, no temporary allowlisted host, no quiet exception that doesn't quietly reopen the hole. The policy makes you say no to convenient-but-unsafe in a hundred small moments.
Saying no a hundred times is a political act, not a technical one.
The deployment pressure gives the epigraph its force. The reason to make this an architectural decision — a constraint the system is built under from the start, not a header bolted on at the end — is that the architectural version is the only one cheap enough to survive the pressure. Decide it once, up front, and every later "can we just…" meets a settled answer instead of a fresh negotiation. Defer it, and you pay Hoffman's multiplier: the same safety, refactored into production code across a team that has spent two years building habits the policy now has to undo. CSP is unusual among security headers in how directly its cost curve tracks when you commit to it — which is to say, it is far more a leadership problem than a configuration one.
Deploying one without breaking your site
Teams stall because they fear the switch-flip, not because they reject a strict CSP: turn on default-src 'none' and watch half the page go dark, in production, with no map of what broke. There's a way to deploy that avoids that moment. The order matters:
- Deny everything by default. Start the policy from
default-src 'none'and grant back only what the site actually uses — the inverse of allowlisting your way up from "the web." You want the final, strict policy from the first line; you're just not going to enforce it yet. - Turn on report-only mode. Ship that strict policy under the
Content-Security-Policy-Report-Onlyheader instead ofContent-Security-Policy. In this mode the browser enforces nothing — every page keeps working exactly as before — but it reports every resource the policy would have blocked, to the reporting endpoint you name. You get a complete, real-traffic inventory of violations without a single user seeing a broken page.
Reporting-Endpoints: csp-endpoint="https://example.com/csp-report"
Content-Security-Policy-Report-Only:
default-src 'none';
script-src 'nonce-…' 'strict-dynamic';
…
report-uri https://example.com/csp-report;
report-to csp-endpoint;
The report-only rollout headers. The strict policy reports violations before it enforces them; Reporting-Endpoints names the group that report-to refers to, and report-uri stays for browsers that don't yet support it.
- Fix what the reports show. Now the work is driven by data, not guesswork: each violation is a real thing to refactor — an inline handler to move into a nonced script, a
javascript:URL to rewrite, a forgotten third-party host to account for. Fix, redeploy, watch the report volume fall. Because nothing is enforced, you can do this at your own pace, on live traffic, with real user behavior surfacing the long-tail cases a staging environment never would. - Flip to enforcing. When the reports go quiet — when the only violations left are ones you understand and have decided to accept — rename the header from
…-Report-OnlytoContent-Security-Policy. Nothing about the policy changes; you're just turning enforcement on for a policy you've already proven fits the site. The switch-flip that teams fear becomes a formality, because you've already watched this exact policy run against production for weeks.
Note
Report-only and enforcing aren't either/or — you can send both headers at once. A common pattern: enforce the policy proven by the report-only phase under Content-Security-Policy while trialing a stricter candidate under Content-Security-Policy-Report-Only. The site stays protected by the enforced one while you gather violation data for the next tightening — the same report-first loop, run continuously.
The report sink doesn't have to be elaborate: a small endpoint that logs the JSON and drops it is enough to start. (This site's own is a tiny edge function that rate-limits, logs one line per report, and samples a subset to a metrics store.) The point of the whole sequence is that it converts the one genuinely scary part of CSP — will this break production? — into an observed fact instead of a leap of faith.
The shape of the whole thing
Step back and CSP is one idea, learned the hard way. The first version asked the wrong question — where is this script from? — and a domain you trust turned out to be a domain an attacker can borrow. The fix was to ask the right question — did I vouch for this exact script? — answered with a per-response nonce, trust that propagates through strict-dynamic, and Trusted Types guarding the DOM sinks underneath. The tooling to verify it exists; the standard is settled. The only thing missing, on almost every site, is the will to do the work — which is the real reason XSS is still with us.
Lessons
- CSP changes the browser's default from "run everything in the page" to "run only what the server vouches for" — a defense against XSS.
- Allowlists of trusted domains failed: the 2016 study found 94.72% of policies trivially bypassable, because trusted domains host gadgets (JSONP, old AngularJS) that run attacker code.
- The fix is to vouch per-script: a fresh per-response nonce (or a content hash), with
strict-dynamicpropagating that trust so no host allowlist is needed; supporting browsers ignore the allowlist entirely. - Nonce, hash, or hybrid: nonces suit per-request HTML, hashes suit statically-built and edge-cached HTML (this site's is hash-based), and a hybrid policy can use a nonce for dynamic content plus hashes for baked-in scripts.
- A backward-compatible strict policy can layer three browser generations in one
script-src(nonce +strict-dynamic+unsafe-inline https:fallback), pairs it withobject-src 'none'/base-uri 'none', and adds Trusted Types for DOM sinks. CSP Evaluator catches the subtle holes. - Deploy it report-only first: ship the final strict policy under
Content-Security-Policy-Report-Only, fix what the violation reports show on real traffic, then rename the header to enforce — no production switch-flip, no guesswork. Sendreport-to(withReporting-Endpoints) alongside the deprecatedreport-uriuntil support is universal. 'unsafe-hashes'is the legacy migration hatch, not a destination: plain hashes don't authorize inline event handlers, and the keyword that does authorizes a code fragment rather than a place, so a blessed handler string can be reattached anywhere in the document.- AI changes the economics, not the execution primitive: a model can synthesize and adapt an injection payload, but it cannot mint a nonce or hash match. Strict CSP remains the browser backstop; tight resource directives, safe rendering, Trusted Types, narrow tools, input validation, and server authorization are still required when trusted code acts as a confused deputy or generated output tries to exfiltrate data through a browser request.
- Almost no one deploys it: ~22% of sites send any CSP (2025, up from ~11% in 2022); of those, ~20% use nonces and ~10%
strict-dynamic(2024) — because a strict CSP is a lot of unglamorous work against code that wasn't built for it.
Practice
References
- Joe Medley. “Content security policy.” web.dev. — the mechanism explained from first principles — the best starting point for a reader new to CSP
- Lukas Weichselbaum. “Adopting a strict CSP.” web.dev. — and csp.withgoogle.com — Google's deployment guidance
- “CSP Evaluator.” — · its source — paste a policy, see the bypasses
- Weichselbaum et al.. “CSP Is Dead, Long Live CSP!.” ACM CCS, 2016. — the allowlist-insecurity study
- Lekies et al.. “Code-Reuse Attacks for the Web: Breaking XSS Mitigations via Script Gadgets.” ACM CCS, 2017. — the residual bypass — gadgets in vouched-for frameworks
- W3C. “CSP Level 3.” W3C. — and MDN's CSP guide — the spec and the reference
- MDN. “CSP: script-src.” MDN. — source expressions, including
'unsafe-hashes'and why hashes alone miss event handlers - MDN. “CSP: report-uri.” MDN. — the deprecation and the send-both migration pattern
- W3C. “Fetch Metadata Request Headers.” W3C Working Draft, 2025. — the request-admission boundary that sits beside CSP
- NIST. “Adversarial Machine Learning: A Taxonomy and Terminology of Attacks and Mitigations.” NIST AI 100-2e2025, 2025. — prompt injection, agent hijacking, arbitrary code execution, and exfiltration
- Google Chrome. “Content scripts.” Chrome for Developers. — isolated and main-world extension execution boundaries
- Google. “Mitigating prompt injection attacks in AI systems.” Google Online Security Blog, 2025. — layered prompt, rendering, URL, and human-confirmation defenses
- Johann Rehberger. “Google Bard Data Exfiltration.” Embrace The Red, 2023. — poisoned Google Doc, Markdown image exfiltration, and a CSP-allowed Apps Script relay
- Johann Rehberger. “Data Exfiltration in Azure OpenAI Playground.” Embrace The Red, 2023. — pasted prompt to automatically rendered image request
- Johann Rehberger. “ChatGPT Data Exfiltration: First Mitigations Implemented.” Embrace The Red, 2023. — external-image exfiltration and early client-side URL mitigations
- Michael Bargury et al.. “EchoLeak: The First Zero-Click Prompt Injection Exploit Turning Microsoft 365 Copilot Against Itself.” AAAI 2026 Spring Symposium Series, 2026. — CVE-2025-32711; crafted email and a CSP-allowed Teams image proxy
- Noma Security. “ForcedLeak: Agent Risks Exposed in Salesforce Agentforce.” Noma Security, 2025. — CRM prompt injection and an expired CSP-allowlisted domain
- Johann Rehberger. “Security Keeps Google Antigravity Grounded.” Embrace The Red, 2025. — poisoned source content, image exfiltration, and tool-mediated command execution
- “HTTP Archive Web Almanac: Security.” — the adoption measurements
How to cite
Mangalapilly, Y. J. (2026, July). Trust No Script. Saṃhitā Notes. https://yesudeep.com/blog/trust-no-script/ @online{mangalapilly2026trust,
author = {Yesudeep Jose Mangalapilly},
title = {Trust No Script},
journal = {Sa\d{m}hit\=a Notes},
year = {2026},
month = {July},
url = {https://yesudeep.com/blog/trust-no-script/},
urldate = {2026-08-12},
} Yesudeep Jose Mangalapilly. “Trust No Script.” Saṃhitā Notes, 2026. https://yesudeep.com/blog/trust-no-script/. TY - ELEC
AU - Mangalapilly, Yesudeep Jose
TI - Trust No Script
T2 - Saṃhitā Notes
PY - 2026
UR - https://yesudeep.com/blog/trust-no-script/
Y2 - 2026-08-12
ER - Webmentions
Annotations
Thank you — your note is held for review and will appear once approved.
Thank you — your note is published.
Please sign in below to leave a note.
