Diego Betto's Blog
Photo by Ronda Dorsey

August 31, 2026 · 6 min di lettura

Advanced CSP: nonces, strict-dynamic and Trusted Types

Why unsafe-inline isn't enough, how CSP nonces and hashes work for a static site, what strict-dynamic is, and what Trusted Types is for.

Condividi:XLinkedInFacebookWhatsApp

In the article on how Content Security Policy works I left something half-said: script-src 'self' blocks external scripts, but if your <head> is full of inline <script> tags — which happens to anyone using Google Analytics, a tag manager, or simply a framework that hydrates components — you almost always end up adding 'unsafe-inline' to the policy. And 'unsafe-inline' on its own defeats much of the reason you set up a CSP in the first place: an attacker who manages to inject a <script> into your HTML (via an unsanitized comment field, say) will still get it executed, because the policy explicitly says “any inline script is fine”.

This article covers the tools that actually solve the problem: nonces, hashes, strict-dynamic, and Trusted Types.

Nonces: a different pass for every request

A nonce (“number used once”) is a random string generated by the server on every single HTTP response, inserted both in the CSP header and as an attribute on the <script> tags you want to authorize:

Content-Security-Policy: script-src 'nonce-8f3jK2mZ9pQ'
<script nonce="8f3jK2mZ9pQ">
  console.log("authorized");
</script>

The browser only runs scripts whose nonce attribute matches the one declared in the header of that specific response. A script injected by an attacker doesn’t know the current nonce (it’s generated server-side, per request, and unpredictable), so even with 'unsafe-inline' removed from the policy, legitimate scripts keep working while injected ones get blocked.

⚠ Attenzione

A nonce requires a dynamically generated response. A fresh nonce on every request means, by definition, that you need a server generating that request on the spot — SSR, or some backend that assembles headers and HTML together. On a fully static site (HTML files already sitting on a CDN, with no server process per visit) there’s no “per request” to hang a nonce on: the HTML is the same, identical, for every visitor until you make a new deploy.

Hashes: the alternative that also works for a static site

If the exact content of an inline script is known in advance and never changes (doesn’t depend on the request, only on the build), you can authorize it with its SHA hash instead of a nonce:

Content-Security-Policy: script-src 'sha256-qznLcsxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx/Tng='

The browser computes the hash of every inline <script>’s content and compares it against the ones authorized in the policy. An attacker can inject whatever script they want, but its hash won’t match any of the whitelisted ones, so the browser blocks it — the same protection as a nonce, without needing to generate anything per request, because the hash is computed once, at build time.

The cost shows up elsewhere: every inline script on the site needs its hash computed and injected into the policy on each build, and if even a single character of the script’s content changes — including serialized data inside the script, not just the code — the hash changes and has to be recomputed. For a site with a handful of static inline scripts (a theme stored in localStorage, a small bootstrap) it’s entirely manageable with a build pipeline that computes the hashes and writes them into the header configuration. For a site that hydrates components with different serialized props on every page — where the inline script’s content, and so its hash, literally changes with every page generated — that pipeline gets a lot more complex, which is why many static sites with interactive islands end up keeping 'unsafe-inline' on script-src anyway: the deliberate trade-off between “simple static site” and “purist CSP” genuinely goes that way sometimes, as long as it stays a choice and not an oversight — and not the final word either: it’s worth revisiting whenever the site’s surface changes.

strict-dynamic: propagating trust, not the allowlist

Many sites load scripts that in turn load other scripts — a tag manager that injects an analytics script, which injects yet another one. With a classic domain-allowlist approach, you’d have to list every single domain in the chain, and the list silently breaks every time one of those services changes provider or adds a new sub-script.

strict-dynamic solves the problem differently: a script authorized via nonce or hash can load other scripts dynamically, and those are automatically trusted, with no need to list their domains.

Content-Security-Policy: script-src 'nonce-8f3jK2mZ9pQ' 'strict-dynamic'

With strict-dynamic present, browsers that support it ignore any domain allowlists in the same directive (this is intentional, for compatibility with older browsers that don’t know about it yet and therefore still need to honor the explicit allowlist) — trust propagates from the already-verified script chain, not from a manually maintained list of hosts.

Trusted Types: closing the door on DOM XSS

Nonces, hashes and strict-dynamic protect against scripts injected into the HTML. But there’s another class of XSS that sidesteps them completely: DOM XSS, where legitimate JavaScript code from your own site passes unsanitized data to a dangerous sink like innerHTML, document.write, or eval.

// No "injected" script here — YOUR code is the problem
element.innerHTML = new URLSearchParams(location.search).get("name");

If a user visits ?name=<img src=x onerror=alert(1)>, that markup ends up inside innerHTML and executes, with no script-src policy able to stop it — we’re not loading an external script or running an inline <script>, we’re simply writing HTML into the DOM.

Trusted Types close this door at the browser level: with the directive active, dangerous sinks only accept TrustedHTML/TrustedScript objects created through a policy you define and control yourself, not arbitrary strings.

Content-Security-Policy: require-trusted-types-for 'script'
const policy = trustedTypes.createPolicy("default", {
  createHTML: (input) => DOMPurify.sanitize(input),
});

element.innerHTML = policy.createHTML(userInput); // goes through sanitization
element.innerHTML = userInput; // TypeError: blocked by the browser

With require-trusted-types-for 'script' active, any direct assignment to a dangerous sink throws a runtime error unless it first goes through a declared policy — the browser itself stops you from writing that bug, not just a linter or code review.

ℹ️ Nota

Browser support is still uneven: Trusted Types has good support on Chromium-based browsers; on Firefox and Safari support has historically arrived later or only partially. Before relying on require-trusted-types-for as your only defense, check the current status on

caniuse

— on browsers that don’t support it the directive is simply ignored, so it’s still wise to sanitize input regardless.

Where to start, in practice

If you’re starting from scratch: a stack with SSR (Next.js, Remix, or any framework with a real server per request) can generate a per-request nonce without much effort — it’s the cleanest solution, and worth implementing right away. On a static site, first check whether you actually need dynamic inline scripts (the logic can often be moved to an external .js file, authorizable with a simple domain in the allowlist, no hashes to recompute); if you can’t avoid them, build-time hashes remain the most solid option. strict-dynamic is worth it as soon as your third-party script chain grows past 2-3 fixed domains. Trusted Types, finally, is the deepest defense but also the one with the highest adoption cost — it requires reviewing every point in the code that writes to the DOM — so it makes the most sense on applications that handle a lot of user-generated content.

As for the rest — what default-src is, how to test a policy with Content-Security-Policy-Report-Only, how to read a violation report — everything I wrote in the base article on CSP still applies.

Condividi:XLinkedInFacebookWhatsApp