Layering CSP Nonces, SRI and Trusted Types

Permalink to "Layering CSP Nonces, SRI and Trusted Types"

Part of Unified Script Policy Architecture, this page shows the exact header set and markup that make a nonce-based Content Security Policy, Subresource Integrity and Trusted Types work together in one response, and the precedence rules that decide what a browser actually enforces.

Quick Reference

Permalink to "Quick Reference"
Control Where it lives What it checks Support
script-src 'nonce-<b64>' response header That this tag was emitted by your server All current browsers
'strict-dynamic' script-src Propagates trust to scripts inserted by trusted code CSP Level 3 engines
object-src 'none' response header Kills plugin-based script execution All current browsers
base-uri 'none' response header Stops <base> injection redirecting relative script URLs All current browsers
integrity="sha384-…" HTML attribute That the bytes fetched hash to a known value All current browsers
crossorigin="anonymous" HTML attribute Mandatory companion to integrity on cross-origin fetches All current browsers
require-trusted-types-for 'script' response header That no raw string reaches a DOM script sink Chromium; other engines lagging
trusted-types <names> response header Which policy names may be created Chromium; other engines lagging

Baseline: nonce plus 'strict-dynamic' for authorisation, integrity plus crossorigin for content, Trusted Types for DOM sinks. Each covers a hole the other two leave open.

The mental model: three gates in series

Permalink to "The mental model: three gates in series"

The three controls are frequently described as alternatives. They are not — they intercept a script at three different moments, and a resource has to survive all of them. Content Security Policy runs first, before any network request leaves the browser: the engine looks at the tag, compares its nonce attribute against the header value, and either allows the fetch or refuses it outright. Subresource Integrity runs last, after the response body has arrived: the browser hashes the bytes and compares them to the integrity attribute, discarding the response on a mismatch. Trusted Types sits on a different axis entirely, guarding the DOM APIs that turn strings into executable code — script.src, innerHTML, eval — regardless of where the string came from.

That ordering has a practical consequence. A tag that CSP refuses never produces an SRI error, because the fetch never happened; if you are debugging a blocked script and see no integrity message, look at the policy first. Conversely a tag that CSP allows can still be discarded at the last moment by an integrity mismatch, which is exactly the case that matters when a trusted CDN starts serving different bytes.

The three gates a script must pass A script tag is first checked against the CSP script-src directive, then fetched with CORS, then digest-checked against its integrity attribute, and only then executed; a failure at any of the first three gates blocks the script entirely. Gates one script tag must pass, in order 1. CSP source nonce matches? 2. CORS fetch anonymous mode 3. SRI digest bytes hash match? 4. Execute in page context any gate fails and the script never executes CSP violation report or SRI console error all gates pass script runs

Precedence: which tokens the browser honours

Permalink to "Precedence: which tokens the browser honours"

A production script-src looks over-specified on purpose. The recommended strict policy carries four kinds of source expression at once, and each engine generation ignores a different subset of them. Two rules from CSP Level 3 do the work. First, if a script-src directive contains any nonce or hash source, 'unsafe-inline' is ignored, so an attacker who injects an inline <script> gets nothing even though the token is present in the header. Second, if the directive contains 'strict-dynamic', all host and scheme sources are ignoredhttps:, cdn.example.com, 'self' — and only nonces, hashes, and trust propagated from already-executing scripts count.

The practical effect is a graceful staircase. A browser that never learned about 'strict-dynamic' still enforces the https: host allow-list. A browser that never learned about nonces still enforces 'unsafe-inline', which is weak but not absent. The newest engines enforce the tightest interpretation. Nothing fails open, and there is exactly one header to maintain.

Token precedence by CSP level A four-row matrix showing that unsafe-inline is honoured only by Level 1 engines, host sources only by Level 1 and Level 2, nonces by Level 2 and Level 3, and strict-dynamic only by Level 3. script-src token CSP 1 engine CSP 2 engine CSP 3 engine 'unsafe-inline' honoured ignored (nonce) ignored (nonce) https: host source honoured honoured ignored (dynamic) 'nonce-r4nd0m...' not understood honoured honoured 'strict-dynamic' not understood not understood honoured

Canonical example: one response, all three controls

Permalink to "Canonical example: one response, all three controls"

The server below mints a nonce per request, emits the full header set, and renders markup where every first-party tag carries the nonce and every cross-origin tag carries both integrity and crossorigin="anonymous". The nonce generation itself is covered in more depth in Generating Per-Request CSP Nonces; the important part here is that the same value reaches both the header and the template.

// server.js — Express
import express from 'express';
import { randomBytes } from 'node:crypto';

const app = express();

// Hashes produced by the build; see your bundler's SRI plugin.
const SRI = {
  vendor: 'sha384-QMgzcs3STAlW+CuuNxe3B8aY1dHHXyKtgHudKaRujcBhDr4ypZEwwAo31aS/l11A',
};

app.use((req, res, next) => {
  res.locals.nonce = randomBytes(16).toString('base64');
  res.setHeader(
    'Content-Security-Policy',
    [
      `script-src 'nonce-${res.locals.nonce}' 'strict-dynamic' https: 'unsafe-inline'`,
      "object-src 'none'",
      "base-uri 'none'",
      "require-trusted-types-for 'script'",
      'trusted-types app-loader sanitizer',
      'report-uri /csp-report',
    ].join('; ')
  );
  // A nonce must never be reused, so the document itself must not be cached.
  res.setHeader('Cache-Control', 'no-store');
  next();
});

app.get('/', (req, res) => {
  const n = res.locals.nonce;
  res.type('html').send(`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Layered script policy</title>
<script nonce="${n}" src="/static/app.js"></script>
<script nonce="${n}"
        src="https://cdn.example.com/vendor-2.4.1.js"
        integrity="${SRI.vendor}"
        crossorigin="anonymous"></script>
</head>
<body><div id="root"></div></body>
</html>`);
});

app.post('/csp-report', express.json({ type: ['application/csp-report', 'application/json'] }), (req, res) => {
  console.warn('csp-violation', JSON.stringify(req.body));
  res.status(204).end();
});

app.listen(8080);

Read the header back as one line and the intent is clear:

Content-Security-Policy: script-src 'nonce-r4nd0mBase64Value==' 'strict-dynamic' https: 'unsafe-inline';
  object-src 'none'; base-uri 'none'; require-trusted-types-for 'script';
  trusted-types app-loader sanitizer; report-uri /csp-report

object-src 'none' and base-uri 'none' are not decoration. Without the first, a <object> or <embed> element becomes a script-execution path that script-src does not cover. Without the second, an injected <base href> silently repoints every relative script URL — including /static/app.js above — at an attacker’s origin, and the nonce travels along with it. The pair is the minimum companion set for any nonce-based policy, and both appear in the wider treatment at Configuring Content Security Policy with SRI.

Note the division of labour in the markup. The first-party bundle gets a nonce and no integrity, because it is deployed together with the HTML and its hash would have to be recomputed on every build anyway. The cross-origin vendor bundle gets the nonce and an integrity attribute and crossorigin="anonymous" — the CORS attribute is mandatory, because the browser refuses to hash an opaque response, and the reasoning behind that rule is unpacked in How CORS and crossorigin Affect SRI.

Propagating trust to scripts inserted at runtime

Permalink to "Propagating trust to scripts inserted at runtime"

'strict-dynamic' is what makes this policy survivable in a real application. Without it, every tag a bundle injects at runtime — a lazily-loaded chunk, a consent-gated analytics tag, a payment iframe helper — would need a nonce, and client code has no reliable way to obtain one after the document has parsed. With it, the browser marks any script element created by already-executing trusted code as trusted too, and the trust propagates down the chain.

The boundary is precise: propagation applies to script elements created programmatically and inserted into the document, not to markup. Anything written through document.write() is explicitly excluded, and markup assigned to innerHTML never executes its <script> children in the first place. That is the entire escape-hatch surface, and it is why 'strict-dynamic' is safe to enable while 'unsafe-inline' is not.

Trust propagation under strict-dynamic A nonced script element is trusted by CSP; a script it creates with createElement and appendChild inherits that trust and runs, while document.write and innerHTML insertion paths are blocked. nonced script trusted by CSP createElement('script') then appendChild document.write with a script tag string innerHTML assigned a script tag string trust propagates runs without a nonce excluded by the spec blocked never executes, and Trusted Types throws

What 'strict-dynamic' does not propagate is integrity. A child script inherits authorisation only, so the loader has to set integrity and crossorigin on every element it creates. With require-trusted-types-for 'script' also in force, script.src becomes a Trusted Types sink, so the URL has to arrive as a TrustedScriptURL rather than a plain string. A single small policy satisfies both requirements:

// loader.js — shipped inside the nonced first-party bundle
const policy = window.trustedTypes
  ? window.trustedTypes.createPolicy('app-loader', {
      createScriptURL: (input) => {
        const url = new URL(input, document.baseURI);
        const allowed = [location.origin, 'https://cdn.example.com'];
        if (!allowed.includes(url.origin)) {
          throw new TypeError(`app-loader: refusing script URL ${url.href}`);
        }
        return url.href;
      },
    })
  : { createScriptURL: (s) => s };

export function loadScript(src, integrity) {
  return new Promise((resolve, reject) => {
    const el = document.createElement('script');
    el.src = policy.createScriptURL(src);
    el.integrity = integrity;            // SRI is never inherited
    el.crossOrigin = 'anonymous';        // required for the digest check
    el.onload = () => resolve(el);
    el.onerror = () => reject(new Error(`blocked or failed: ${src}`));
    document.head.appendChild(el);
  });
}

That is the minimum viable policy: one name, one callback, an origin allow-list, and a throw on anything else. Apps that also write markup add a createHTML callback, usually delegating to a sanitiser as described in Writing a Trusted Types Policy with DOMPurify. The mechanics of the enforcement directive itself are covered in Enforcing require-trusted-types-for script, and the injection pattern generalises in Adding Integrity to Runtime-Injected Scripts.

Variants

Permalink to "Variants"

Cloudflare Worker at the edge

Permalink to "Cloudflare Worker at the edge"

When the HTML is static and cached, generate the nonce at the edge and stream it into both the header and the markup with HTMLRewriter, so the origin never has to opt out of caching:

export default {
  async fetch(request, env, ctx) {
    const nonce = btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(16))));
    const upstream = await fetch(request);
    if (!upstream.headers.get('content-type')?.includes('text/html')) return upstream;

    const csp = [
      `script-src 'nonce-${nonce}' 'strict-dynamic' https: 'unsafe-inline'`,
      "object-src 'none'",
      "base-uri 'none'",
      "require-trusted-types-for 'script'",
      'trusted-types app-loader sanitizer',
    ].join('; ');

    const res = new Response(upstream.body, upstream);
    res.headers.set('Content-Security-Policy', csp);
    return new HTMLRewriter()
      .on('script', { element: (el) => el.setAttribute('nonce', nonce) })
      .transform(res);
  },
};

The origin ships placeholder-free HTML; the Worker stamps the nonce. HTMLRewriter only touches the nonce attribute, so any integrity and crossorigin attributes baked in by the build pass through untouched.

Hashes instead of a nonce

Permalink to "Hashes instead of a nonce"

If the page is fully static and served from a CDN with no per-request compute, swap the nonce for hash sources. Every inline script gets its own 'sha384-…' entry in script-src, and 'strict-dynamic' still propagates trust from those scripts to whatever they insert. The migration path is covered in Migrating from unsafe-inline to Hash-Based CSP.

Requiring integrity policy-side

Permalink to "Requiring integrity policy-side"

A nonce alone cannot force an author to add integrity. Two mechanisms have tried to close that gap — the older require-sri-for directive, which no shipping browser enforces today, and the newer Integrity-Policy response header, which is rolling out in Chromium and not yet in other engines. Both are discussed in Combining require-sri-for with CSP. Until support broadens, a build-time lint that fails when a cross-origin <script> lacks integrity is the control that actually works.

Gotchas and Edge Cases

Permalink to "Gotchas and Edge Cases"
  • Omitting crossorigin="anonymous" silently disables the integrity check by blocking the load. A cross-origin script fetched in no-CORS mode yields an opaque response the browser cannot hash, so the resource is discarded even when the bytes are correct. Every tag with an integrity attribute needs the CORS attribute alongside it, and the server must return a matching Access-Control-Allow-Origin.

  • Reusing a nonce across responses defeats it entirely. The nonce’s only security property is that an injecting attacker cannot guess it. A nonce embedded in a page cached by a CDN, a service worker or a reverse proxy becomes a public constant, at which point script-src 'nonce-…' is equivalent to 'unsafe-inline'. Send Cache-Control: no-store on the document, or inject the nonce at the edge.

  • Creating a policy whose name is not in the trusted-types directive throws. trustedTypes.createPolicy('app-loader', …) raises a TypeError when app-loader is absent from the allow-list, and calling it twice with the same name throws unless the directive includes 'allow-duplicates'. Bundle splitting that evaluates the loader module more than once will trip this.

  • 'strict-dynamic' retires your host allow-list, quietly. Any third-party tag that was previously permitted because cdn.partner.example appeared in script-src stops loading the moment the token is added, unless it is injected by trusted code. Inventory the plain <script src> tags in your templates before you enable it, then stamp each one with the nonce.

  • getAttribute('nonce') returns an empty string by design. Browsers hide the nonce content attribute after applying the policy so that a CSS-based side channel cannot exfiltrate it. Read document.currentScript.nonce — the IDL property — if you genuinely need the value at runtime, though under 'strict-dynamic' you usually do not.

  • Trusted Types support is uneven. Enforcement shipped first in Chromium and other engines have been slower to follow, so treat it as a hardening layer that a share of your traffic will not receive. The nonce and integrity gates carry the load everywhere; check current support tables before assuming coverage.

Verification Steps

Permalink to "Verification Steps"

1. Confirm the nonce rotates and the header is complete

Permalink to "1. Confirm the nonce rotates and the header is complete"
curl -sI https://app.example.com/ | grep -i content-security-policy
curl -sI https://app.example.com/ | grep -i content-security-policy

The two lines must differ in the 'nonce-…' value and be identical everywhere else. An identical nonce on both requests means something upstream is caching the document.

2. Confirm the integrity gate actually fires

Permalink to "2. Confirm the integrity gate actually fires"

Change one character inside the integrity value in the served HTML and reload with the console open. Chromium prints:

Failed to find a valid digest in the 'integrity' attribute for resource
'https://cdn.example.com/vendor-2.4.1.js' with computed SHA-384 integrity
'QMgzcs3STAlW+CuuNxe3B8aY1dHHXyKtgHudKaRujcBhDr4ypZEwwAo31aS/l11A'.
The resource has been blocked.

The message reporting the computed digest is the useful part — paste it back into the attribute to confirm the file, then find out why the deployed bytes changed.

3. Confirm the CSP gate blocks an unnonced tag

Permalink to "3. Confirm the CSP gate blocks an unnonced tag"

Append a script tag with no nonce to the document body from the console:

document.body.insertAdjacentHTML('beforeend', '<script src="/static/app.js"><\/script>');

The parser-inserted tag carries no nonce and no propagated trust, so the browser refuses it with Refused to load the script '…' because it violates the following Content Security Policy directive: "script-src 'nonce-…' 'strict-dynamic' https: 'unsafe-inline'".

4. Confirm the Trusted Types sink is guarded

Permalink to "4. Confirm the Trusted Types sink is guarded"
document.createElement('script').src = 'https://evil.example.com/x.js';

With require-trusted-types-for 'script' enforced, the assignment throws a TypeError mentioning that the document requires a TrustedScriptURL assignment, and a violation is reported with effectiveDirective: "require-trusted-types-for". If it silently succeeds, the directive is not reaching the browser — check for a Report-Only header where you meant an enforcing one.

5. Watch the reports before you trust the rollout

Permalink to "5. Watch the reports before you trust the rollout"

Point report-uri or a report-to endpoint at a collector and let real traffic run for a few days; the pipeline is described in Collecting CSP Violation Reports with the Reporting API. A quiet report stream from a representative sample of browsers is the only evidence that the three gates are not breaking legitimate functionality.

Frequently Asked Questions

Permalink to "Frequently Asked Questions"
Does a nonce make the integrity attribute redundant?

No. A nonce authorises a tag; it says nothing about the bytes that tag fetches. If a CDN is compromised and serves different JavaScript from the same URL, the nonce still matches and the script still runs. The integrity attribute is the only control in the set that checks the response body. The two answer different questions and both have to pass.

Why does the policy still list 'unsafe-inline' and https:?

They are backwards-compatibility fallbacks. A CSP Level 3 browser ignores 'unsafe-inline' whenever a nonce or hash is present in the same directive, and ignores host and scheme sources whenever 'strict-dynamic' is present. Older engines that do not understand the newer tokens fall back to the looser ones instead of failing open with no policy at all.

Do scripts inserted at runtime need their own nonce?

Not when 'strict-dynamic' is in force. A script element created with document.createElement and appended by already-trusted code inherits that trust, so no nonce is required. You still set integrity and crossorigin on it yourself, because SRI is never inherited. Markup written with document.write is explicitly excluded and stays blocked.

What is the minimum Trusted Types policy an app needs?

One named policy with a createScriptURL callback that validates the origin of any URL your loader assigns to script.src, plus a createHTML callback only if the app writes markup into innerHTML. Name it in the trusted-types directive. Add a default policy only as a temporary bridge while you migrate the remaining sinks.

Can I cache an HTML page that carries a nonce?

Not at a shared cache, and not with the nonce baked in. A nonce reused across responses is no longer unpredictable, which is the single property the whole mechanism rests on. Either mark the document no-store, or cache the body and inject a fresh nonce into both the header and the markup at the edge on every request.

Permalink to "Related"

Related Articles

Rolling Out a Script Policy in Report-Only Mode
Auditing a Script Policy for Gaps
Unified Script Policy Architecture Runtime Policy Enforcement & T…