You're viewing the readable version of this site. The interactive extras (search, diagrams, read-aloud) need JavaScript and a current browser. Enable JavaScript; if it is already enabled, update your browser.

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.

· · 38 min read

security, csp, web, xss, browsers, gen-ai, dissecting-systems

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
APA
Mangalapilly, Y. J. (2026, July). Trust No Script. Saṃhitā Notes. https://yesudeep.com/blog/trust-no-script/
BibTeX
@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},
}
Plain
Yesudeep Jose Mangalapilly. “Trust No Script.” Saṃhitā Notes, 2026. https://yesudeep.com/blog/trust-no-script/.
RIS
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 same pattern, two layers. An OS will load any driver by default — so it learned to demand a valid signature before granting ring-0. A browser runs any script by default — so CSP learned to demand a valid nonce before granting the page's authority. Vouched-for code runs; unsigned, un-nonced code is blocked.

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 allowlist trusts addresses, not bytes. The host passes the CSP, but a JSONP callback parameter turns the trusted response into attacker-chosen script.

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 128\ge 128 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.

A strict CSP vouches for one nonce'd root script; 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.

AI makes the attacker adaptive, not authoritative

The payload used to be the expensive part. An attacker had to study the application, find an executable sink, shape a string for that exact parser, and often iterate by hand. A model can now do much of that work: read page structure, adapt a payload to a framework, choose among known gadgets, or turn attacker-controlled prose into JavaScript. The model may run on the device, in a browser assistant, in the application backend, or behind somebody else's API. Its location does not change the browser's final question: is this code authorized to execute in this document?

Generative systems make strict CSP more important. A model can synthesize bytes, but it cannot synthesize the server's fresh nonce or make different bytes match a build-time hash. If attacker instructions cause an assistant or application to append an un-nonced <script>, emit an inline event handler, navigate to a javascript: URL, call eval() without 'unsafe-eval', or fetch a parser-inserted script from a newly chosen origin, the browser still refuses the execution. The attacker has acquired a faster exploit author, not the authority that the policy withholds.

Prompt injection is attacker-controlled text that a model mistakes for instructions, causing an AI application or agent to act outside its intended task. NIST's adversarial machine-learning taxonomy includes agent attacks that lead to arbitrary code execution or data exfiltration.

That distinction is architectural. Generative systems erase the old practical distance between data that describes code and code ready to run: a paragraph, DOM fragment, retrieved document, image, or tool result can recruit a model to produce the payload. CSP restores a browser-enforced boundary after the model's reasoning. The browser will execute only a nonce- or hash-vouched root and the descendants the policy permits.

But the earlier script-gadget limit becomes more important too. If a trusted, nonced application script hands model output to eval() under a policy that allows it, turns an attacker-controlled URL into a dynamically-created script, or exposes a powerful agent tool that can transfer money or delete data, CSP cannot distinguish the intended instruction from the injected one. The CSP3 specification explicitly warns that strict-dynamic will allow an arbitrary script when attacker-controlled data determines the URL loaded by a trusted script. In that case the trusted script is a confused deputy: code the server authorized, exercising its real authority for the wrong principal.

That warning is not advice to remove strict-dynamic. It is advice to treat a vouched-for root script as an authority-bearing principal. The unsafe version lets attacker-controlled data choose what that principal vouches for:

const script = document.createElement("script");
script.src = new URLSearchParams(location.search).get("plugin");
document.head.append(script);

If the page's root script is nonce- or hash-authorized, strict-dynamic permits the non-parser-inserted child script. The attacker did not forge the root's nonce or hash; trusted code delegated its authority to the attacker's URL.

When runtime loading is genuinely necessary, make untrusted input select from a closed, reviewed manifest rather than become a URL:

const plugins = new Map([
  ["charts", "/js/charts.js"],
  ["search", "/js/search.js"],
]);

const requested = new URLSearchParams(location.search).get("plugin");
const selected = requested === null ? undefined : plugins.get(requested);
if (selected !== undefined) {
  const script = document.createElement("script");
  script.src = selected;
  document.head.append(script);
}

A Map also avoids inherited-property names becoming accidental choices. Better still, avoid runtime script creation when static module imports can express the same graph. The repair is to remove the attacker-controlled script URL, not to remove strict-dynamic and fall back to trusting every same-origin script.

The trusted root is the deputy. With strict-dynamic, a child script inherits the root's authority; safety depends on whether its URL comes from attacker-controlled data or a closed, reviewed manifest.

Browser assistants add one more boundary worth naming. A Chrome extension content script normally runs in an isolated JavaScript world with its own CSP while sharing access to the page's DOM; when an extension injects into the page's MAIN world, the page's CSP applies. A site policy therefore constrains code entering the site's execution world, not every privileged component installed in the browser.

What the attacks look like

The most instructive public demonstrations did not begin with a <script> tag. They began with ordinary data: a shared document, email, CRM record, pasted prompt, or source file. The attacker hid instructions in that data; an AI assistant read it together with private context; and the assistant emitted a Markdown image whose URL contained the private data. If the product rendered the image automatically, the browser made the request and delivered the secret to the server named in the URL.

An indirect prompt injection becomes a browser request. The model combines poisoned content with private context and writes an image URL; automatic rendering turns the URL into an outbound request.

That was the shape of the 2023 Google Bard Extensions demonstration. A poisoned Google Doc instructed Bard to include chat data in an image URL. Bard's CSP blocked a direct image request to the attacker's host, but the policy allowed broad Google-controlled origins. A Google Apps Script endpoint could therefore receive the allowed request and relay or record the data. The first boundary worked; the allowlisted service became the gadget.

A host allowlist can block the destination and still permit the route. A CSP-approved proxy or user-content host receives the browser request and forwards the secret to the attacker.

The same pattern reappeared in EchoLeak (CVE-2025-32711), where a crafted email induced Microsoft 365 Copilot to produce an image reference through a CSP-allowed Microsoft Teams proxy, and in ForcedLeak, where an Agentforce response targeted an expired my-salesforce-cms.com hostname that remained in Salesforce's img-src allowlist. In ForcedLeak, the researchers could register the expired domain. The disclosed cases are not arguments against CSP. They are arguments against confusing an allowed hostname with an authorized information flow.

The public research table is a small data set, not evidence that each technique was exploited in the wild. The disclosed techniques are useful because the same chain survived several products, model families, and input channels.

Public AI injection and CSP-boundary research, 2023–2025. The incidents cluster around generated resource URLs, overbroad trusted hosts, and privileged tools rather than one model or vendor.
Year System or report Poisoned input Browser or authority channel Policy lesson
2023 Azure AI Playground Pasted prompt Automatically rendered Markdown image Treat generated URLs as untrusted; do not auto-fetch arbitrary remote images.
2023 Google Bard Extensions Shared Google Doc Google Apps Script endpoint allowed by img-src A broad first-party allowlist can contain an attacker-usable relay.
2023 ChatGPT Prompt or retrieved context Automatically rendered external image URL Client-side URL checks must cover redirects, encoding, and every rendering path.
2025 EchoLeak Crafted email Microsoft Teams image proxy allowed by CSP An approved proxy must validate both destination and data-bearing URL components.
2025 ForcedLeak Web-to-Lead CRM record Expired but allowlisted Salesforce CMS hostname CSP inventories need domain-lifecycle review; stale trusted origins are capabilities.
2025 Google Antigravity research Poisoned source and tool content Image rendering plus authorized tools CSP can constrain browser fetches; tool calls and host command execution need separate authorization.

Layered mitigations reflect that shape. Google's prompt-injection defense overview, for example, describes classifiers, instruction reinforcement, suspicious-URL redaction, blocking external image rendering, and human confirmation for consequential actions. None replaces a strict CSP; none is replaced by one.

Each consequence belongs to a different enforcement boundary. Strict CSP stops unauthorized script execution; safe rendering and Trusted Types keep output as data; resource policy stops image exfiltration; server authorization governs tools.

AI can manufacture the payload and search for the path. It still cannot mint the page's authority — unless trusted application code lends that authority to it.

The resulting defense is a stack, not a slogan: keep model output as data; validate tool arguments and script URLs; omit 'unsafe-eval'; use Trusted Types at executable DOM sinks; give agents narrow, server-authorized tools; and let strict CSP remain the browser-enforced backstop. Strict CSP stops unauthorized code. It does not stop authorized code from obeying unauthorized instructions.

One of those boundaries sits on the other side of the wire and is easy to mistake for another security header. A CSP is enforced by the browser, on a response that already exists; fetch metadata is enforced by your server, before it decides to answer at all. The Sec-Fetch-* headers tell you who initiated a request and how, which turns cross-site forgeries and a broad class of cross-site leaks into a rejection rule that runs ahead of authentication. That boundary deserves its own treatment, and gets one in a piece still being written.

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-source expression 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.random or a language PRNG; use crypto.randomBytes (Node), os.urandom or secrets (Python), or the getrandom() 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) \to about 24 base64 characters, like the t0jzsyT-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-abc123 is 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, 128\ge 128 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.

Prediction checkpoint. Commit to a CSP3-capable browser's trust boundary before consulting the live evaluator.
One header, three browsers. The worked 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:

Strict mechanisms are still the minority. Of sites that send any CSP, what fraction use each mechanism (2024 Web Almanac, desktop): nearly all still carry 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.

Why the keyword is named 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:

  1. 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.
  2. Turn on report-only mode. Ship that strict policy under the Content-Security-Policy-Report-Only header instead of Content-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.

  1. 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.
  2. 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-Only to Content-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-dynamic propagating 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 with object-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. Send report-to (with Reporting-Endpoints) alongside the deprecated report-uri until 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

Retrieval. Recover the root and propagation rule of a strict CSP.
Discrimination. Choose the trust token that matches the document's lifecycle.
Transfer. Diagnose a policy that protects script tags but leaves a second execution path open.

References

  1. Joe Medley. “Content security policy.” web.dev. — the mechanism explained from first principles — the best starting point for a reader new to CSP
  2. Lukas Weichselbaum. “Adopting a strict CSP.” web.dev. — and csp.withgoogle.com — Google's deployment guidance
  3. CSP Evaluator.” — · its source — paste a policy, see the bypasses
  4. Weichselbaum et al.. “CSP Is Dead, Long Live CSP!.” ACM CCS, 2016. — the allowlist-insecurity study
  5. 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
  6. W3C. “CSP Level 3.” W3C. — and MDN's CSP guide — the spec and the reference
  7. MDN. “CSP: script-src.” MDN. — source expressions, including 'unsafe-hashes' and why hashes alone miss event handlers
  8. MDN. “CSP: report-uri.” MDN. — the deprecation and the send-both migration pattern
  9. W3C. “Fetch Metadata Request Headers.” W3C Working Draft, 2025. — the request-admission boundary that sits beside CSP
  10. 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
  11. Google Chrome. “Content scripts.” Chrome for Developers. — isolated and main-world extension execution boundaries
  12. Google. “Mitigating prompt injection attacks in AI systems.” Google Online Security Blog, 2025. — layered prompt, rendering, URL, and human-confirmation defenses
  13. Johann Rehberger. “Google Bard Data Exfiltration.” Embrace The Red, 2023. — poisoned Google Doc, Markdown image exfiltration, and a CSP-allowed Apps Script relay
  14. Johann Rehberger. “Data Exfiltration in Azure OpenAI Playground.” Embrace The Red, 2023. — pasted prompt to automatically rendered image request
  15. Johann Rehberger. “ChatGPT Data Exfiltration: First Mitigations Implemented.” Embrace The Red, 2023. — external-image exfiltration and early client-side URL mitigations
  16. 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
  17. Noma Security. “ForcedLeak: Agent Risks Exposed in Salesforce Agentforce.” Noma Security, 2025. — CRM prompt injection and an expired CSP-allowlisted domain
  18. Johann Rehberger. “Security Keeps Google Antigravity Grounded.” Embrace The Red, 2025. — poisoned source content, image exfiltration, and tool-mediated command execution
  19. HTTP Archive Web Almanac: Security.” — the adoption measurements

How to cite

APA
Mangalapilly, Y. J. (2026, July). Trust No Script. Saṃhitā Notes. https://yesudeep.com/blog/trust-no-script/
BibTeX
@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},
}
Plain
Yesudeep Jose Mangalapilly. “Trust No Script.” Saṃhitā Notes, 2026. https://yesudeep.com/blog/trust-no-script/.
RIS
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  - 

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.