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 a real 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-07-03},
} 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-07-03
ER - An anatomy of the hardest-to-deploy security header, from one I wrote for production. 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 modern nonce-and-strict-dynamic design fixes it, how to read a real strict policy with Google's CSP Evaluator, and why — despite all this — almost no one on the web has it.
A strict CSP is one of the most finicky things I've deployed — 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 point above 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. This is a tour of why — the technical why, and a little of the organizational one.
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) — An attacker's goal is straightforward: inject a piece of their own JavaScript to run inside your page. 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, either way, fire authenticated requests as the signed-in user. 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. 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. 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. 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.
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": …})
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. There's a full anatomy of a good nonce just below. 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.
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.
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 the worked policy below. 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. You usually get this for free: 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 — and a strict policy usually carries 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 modern 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. I wrote it up separately: 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 real one
Here is the load-bearing part of the policy I deployed for Google Fiber. I've elided the dozens of sha256- hashes and the long resource allowlists — they're real but repetitive — to keep the structure visible:
default-src 'none';
script-src 'nonce-t0jzsyT-QmOO4xrJxZpuwA==' 'strict-dynamic'
'sha256-…' 'sha256-…' ← (dozens elided)
'unsafe-inline' https:;
object-src 'none';
base-uri 'none';
frame-ancestors 'none';
require-trusted-types-for 'script';
trusted-types default dompurify google#safe nextjs#bundler …;
report-uri https://csp.withgoogle.com/csp/gfiber-…;
upgrade-insecure-requests;
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 modern (CSP3) 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 figure below is that script-src line as 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 evaluator below 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.
Why almost no one has this
Which is the sobering part. Everything above 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. This is the picture in the 2024 Web Almanac, as a share of 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. 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 today (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. This last 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.
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 in today, 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. These aren't bad instincts — they're 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.
This is where the epigraph at the top earns its place. 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.
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. - A real strict policy layers 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. - 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.
References
- “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
- “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-07-03},
} 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-07-03
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.
