Deploying Defense-in-Depth Script Controls

Permalink to "Deploying Defense-in-Depth Script Controls"

Part of Coordinating SRI, CSP & Trusted Types, this page gives you one copy-pasteable HTTP response that layers a nonce-based Content Security Policy, require-trusted-types-for, and an SHA-384 SRI script tag — then shows how to emit it from Nginx, a Cloudflare Worker, and an Express app.

Quick Reference

Permalink to "Quick Reference"
Layer Mechanism Threat stopped Lives in
Source policy script-src 'nonce-…' Unauthorized / injected script source Content-Security-Policy header
Fetch integrity integrity="sha384-…" + crossorigin="anonymous" Tampered CDN / origin bytes Markup (<script>, <link>)
DOM sink guard require-trusted-types-for 'script' DOM-based XSS via string sinks Content-Security-Policy header
Policy allow-list trusted-types default Rogue Trusted Types policy creation Content-Security-Policy header
Telemetry report-to csp-endpoint Blind spots on any violation Content-Security-Policy header

The nonce and the SRI hash are regenerated per deploy or per request; the Trusted Types policy is registered once at startup. All of it ships together in a single response.

Layered Script Controls in One Response A script request passes left to right through three gates — a nonce-based CSP source check, an SRI byte-integrity check, and a Trusted Types DOM-sink check — and only reaches execution if all three allow it; failing any one gate blocks the resource. Script request CSP nonce source allowed? script-src 'nonce-' SRI sha384 bytes authentic? integrity + CORS Trusted Types sink guarded? require-...-for script Execute ✓ Any gate fails → Block ✗ a failure at any gate blocks the resource

Why Layer Three Controls

Permalink to "Why Layer Three Controls"

A nonce authorizes which script may run but never inspects its bytes; SRI verifies the bytes but never restricts which sources are permitted; Trusted Types ignores the network entirely and instead stops runtime code from feeding attacker strings into innerHTML and other sinks. Deploy one alone and you leave exactly one of those three doors open. The response below shuts all three in a single exchange, and the reasoning behind the ordering is detailed in the parent workflow.

Canonical Implementation

Permalink to "Canonical Implementation"

One response. The CSP header carries the nonce source policy, the Trusted Types requirement, and the reporting endpoint; the markup carries the SRI-pinned external script and a nonce-authorized inline script.

HTTP/2 200
content-type: text/html; charset=utf-8
content-security-policy:
  default-src 'self';
  script-src 'self' 'nonce-r4nd0mBase64==' https://cdn.example.com;
  style-src 'self' https://cdn.example.com;
  require-trusted-types-for 'script';
  trusted-types default;
  object-src 'none';
  base-uri 'none';
  report-to csp-endpoint

Read that header left to right and each directive does one job. default-src sets the floor, script-src decides which sources may run, require-trusted-types-for moves the check from the network to the DOM sink, trusted-types limits which policy names may be created, and report-to names the group that receives a violation when any of them fires.

Anatomy of the Composed CSP Header The Content-Security-Policy header is split into five labelled directive segments — default-src, script-src with the nonce, require-trusted-types-for, trusted-types, and report-to — with a bracket marking the middle three as the enforced gates. Content-Security-Policy: default-src 'self' script-src 'nonce-x' require-trusted-types-for 'script' trusted-types default report-to csp-endpoint Baseline: same-origin for every fetch type Source gate: nonce plus allowed CDN host Sink gate: raw strings throw at DOM sinks Allow-list: only the policy named default Telemetry: violations POSTed to the group Three enforced gates: source, sink, policy allow-list
<!doctype html>
<html>
  <head>
    <!-- External dependency: bytes pinned by SRI, source allowed by script-src host -->
    <script
      src="https://cdn.example.com/app.4f9c2a.js"
      integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/ux8JHrklT1RNnZ3d8l6+pNnS3lPmW2"
      crossorigin="anonymous"></script>

    <!-- Inline bootstrap: authorized by the per-request nonce -->
    <script nonce="r4nd0mBase64==">
      // Register the Trusted Types policy before any sink is touched
      if (window.trustedTypes && trustedTypes.createPolicy) {
        trustedTypes.createPolicy('default', {
          createHTML: (input) => DOMPurify.sanitize(input, { RETURN_TRUSTED_TYPE: false }),
        });
      }
    </script>
  </head>
  <body><!-- … --></body>
</html>

The external <script> must carry crossorigin="anonymous" alongside its integrity attribute. Without it the browser makes a no-CORS request, receives an opaque response whose bytes it cannot read, and the SRI check cannot run — one of the three layers silently disappears. The inline <script> needs no integrity; it is authorized by the nonce, which the server regenerates on every response.

The createHTML callback shown above is deliberately minimal — a real sanitizer configuration, including which tags and attributes survive and how to return a genuine TrustedHTML, is worked through in Writing a Trusted Types Policy with DOMPurify. Note that the report-to group at the end of the header collects violations from all three layers, so the same endpoint that receives a blocked-inline-script report also receives the SRI failure report; splitting those streams is the subject of Alerting on SRI Failures from CSP Reports.

Variant Examples

Permalink to "Variant Examples"

Before the code, be clear about ownership. No single component can emit all three layers: the hash is a build-time fact, the nonce is a per-response fact, and the Trusted Types policy is a runtime fact registered inside the page. The browser is the only place all three are actually enforced, which is why a deployment that looks correct in your proxy config can still ship a page with two of the three layers missing.

Which Component Owns Each Layer A four-column matrix mapping the nonce, SRI integrity attribute, Trusted Types policy and violation reporting against the build pipeline, edge proxy, application runtime and browser, showing that production is split across all four and enforcement always happens in the browser. Control layer Build pipeline Edge / proxy App runtime Browser Nonce (script-src) not involved mint + inject template reads it match or refuse SRI integrity attr compute sha384 pass bytes through emit the attribute hash or block Trusted Types policy bundles DOMPurify directive only createPolicy call guard the sinks Violation reporting not involved report-to header collector endpoint POSTs the report

Nginx — emit the header and a per-request nonce

Permalink to "Nginx — emit the header and a per-request nonce"

Nginx generates a nonce with $request_id (a unique 32-hex-character value per request), exposes it to the page via a sub-filter, and stamps it into the CSP header.

server {
  # $request_id is unique per request — use it as the nonce seed
  set $csp_nonce $request_id;

  add_header Content-Security-Policy
    "default-src 'self'; script-src 'self' 'nonce-$csp_nonce' https://cdn.example.com; require-trusted-types-for 'script'; trusted-types default; object-src 'none'; base-uri 'none'; report-to csp-endpoint" always;

  # Inject the same nonce into inline <script> tags rendered with a placeholder
  sub_filter_once off;
  sub_filter 'NONCE_PLACEHOLDER' $csp_nonce;
}

The application template emits nonce="NONCE_PLACEHOLDER", and Nginx rewrites it to match the header value on the way out.

Cloudflare Worker — generate the nonce at the edge

Permalink to "Cloudflare Worker — generate the nonce at the edge"

A Worker can mint a nonce, rewrite the CSP header, and inject the nonce into inline scripts with HTMLRewriter, all without touching the origin.

export default {
  async fetch(request, env) {
    const response = await fetch(request);
    const nonce = btoa(crypto.getRandomValues(new Uint8Array(16)).join('')).slice(0, 22);

    const csp = [
      "default-src 'self'",
      `script-src 'self' 'nonce-${nonce}' https://cdn.example.com`,
      "require-trusted-types-for 'script'",
      "trusted-types default",
      "object-src 'none'",
      "base-uri 'none'",
      "report-to csp-endpoint",
    ].join('; ');

    const rewritten = new Response(response.body, response);
    rewritten.headers.set('Content-Security-Policy', csp);

    return new HTMLRewriter()
      .on('script[nonce]', {
        element(el) { el.setAttribute('nonce', nonce); },
      })
      .transform(rewritten);
  },
};

Express + helmet — nonce middleware

Permalink to "Express + helmet — nonce middleware"

Helmet composes the CSP; a small middleware attaches a fresh nonce to each response for the template to read.

import express from 'express';
import helmet from 'helmet';
import crypto from 'node:crypto';

const app = express();

app.use((req, res, next) => {
  res.locals.nonce = crypto.randomBytes(16).toString('base64');
  next();
});

app.use((req, res, next) =>
  helmet({
    contentSecurityPolicy: {
      directives: {
        defaultSrc: ["'self'"],
        scriptSrc: ["'self'", `'nonce-${res.locals.nonce}'`, 'https://cdn.example.com'],
        styleSrc: ["'self'", 'https://cdn.example.com'],
        'require-trusted-types-for': ["'script'"],
        'trusted-types': ['default'],
        objectSrc: ["'none'"],
        baseUri: ["'none'"],
      },
    },
  })(req, res, next),
);

// In the template: <script nonce="<%= nonce %>"> … </script>
// External deps keep their sha384 integrity + crossorigin="anonymous"

Gotchas and Edge Cases

Permalink to "Gotchas and Edge Cases"
  • Omitting crossorigin="anonymous" disables the SRI layer. This is the single most common defect in a layered deployment. A cross-origin <script> or <link> with an integrity attribute but no crossorigin triggers a no-CORS fetch, the browser gets an opaque response it cannot hash, and the integrity gate never runs. Always pair integrity with crossorigin="anonymous" on every cross-origin tag.
  • A reused nonce is worse than none. The nonce must be cryptographically random and regenerated per response. Caching an HTML page with a baked-in nonce turns script-src 'nonce-…' into effectively unsafe-inline, and an attacker who reads the page can reuse the value. Mark nonce-bearing HTML Cache-Control: no-store or regenerate at the edge.
  • Enforcing Trusted Types before auditing sinks breaks the app. require-trusted-types-for 'script' makes every raw-string sink assignment throw. Ship it in Content-Security-Policy-Report-Only first, collect the violated sinks, wrap them in the default policy, then enforce — the staged sequence, including how long to soak and what report volume signals readiness, is set out in Rolling Out a Script Policy in Report-Only Mode.
  • Registering the Trusted Types policy too late still throws. The default policy must be created before any code path touches a sink. If a bundled library assigns innerHTML during its own initialization and your policy registers afterward, that early assignment fails. Register the policy in the first inline bootstrap script.
  • Header truncation kills every layer at once. The composed CSP plus the nonce can grow past a proxy or WAF header limit (commonly 8 KB), and a truncated Content-Security-Policy is silently dropped — taking the nonce policy and Trusted Types with it. Keep the policy lean and measure the header length in CI.

Verification Steps

Permalink to "Verification Steps"

1. Confirm the composed header and its size

Permalink to "1. Confirm the composed header and its size"
curl -sI https://app.example.com/ \
  | awk 'BEGIN{IGNORECASE=1}/^content-security-policy:/{print; print "bytes:", length($0)}'

Expected output — the header present, comfortably under the ceiling:

content-security-policy: default-src 'self'; script-src 'self' 'nonce-…' https://cdn.example.com; require-trusted-types-for 'script'; …
bytes: 268

2. Confirm the nonce is unique per request

Permalink to "2. Confirm the nonce is unique per request"
for i in 1 2; do curl -sI https://app.example.com/ | grep -oi "nonce-[A-Za-z0-9+/=]*"; done

Expected output — two different values:

nonce-r4nd0mBase64==
nonce-9x2Qm1p0Ab==

If the two values are identical, nonce generation is cached — a critical misconfiguration.

3. Confirm each layer blocks its own threat

Permalink to "3. Confirm each layer blocks its own threat"

Load the page with DevTools open and drive three negative tests:

- Flip one byte of the CDN asset  → net::ERR_SRI_FAILED (SRI layer)
- Inject <script> with no nonce    → "Refused to execute inline script … script-src" (CSP layer)
- element.innerHTML = userString    → "requires 'TrustedHTML' assignment" (Trusted Types layer)

4. Gate the build on SRI coverage and header size

Permalink to "4. Gate the build on SRI coverage and header size"
# GitHub Actions
- name: Verify layered controls
  run: |
    node scripts/check-sri-coverage.mjs dist/index.html
    HDR=$(node scripts/render-csp.mjs | wc -c)
    if [ "$HDR" -gt 8192 ]; then echo "CSP header too large: $HDR bytes"; exit 1; fi
    echo "SRI coverage OK; CSP header $HDR bytes"

A non-zero exit blocks promotion before an integrity-less tag or an oversized header can reach production.

Frequently Asked Questions

Permalink to "Frequently Asked Questions"
Why layer a nonce CSP, SRI, and Trusted Types instead of picking one?

Each stops a threat the others miss: the nonce blocks unauthorized and injected script sources, SRI blocks tampered CDN bytes, and Trusted Types blocks DOM-based XSS through string sinks. A single control leaves one of those three gaps open, so a layered response is the only way to cover the full script attack surface.

Can I set the nonce, SRI, and Trusted Types from a reverse proxy alone?

Partly. A proxy like Nginx or a Cloudflare Worker can emit the CSP header and inject a per-request nonce, but SRI integrity attributes belong in the markup your build produces, and the Trusted Types policy is registered by first-party JavaScript at runtime. The proxy owns the header; the application owns the attributes and the policy.

Does the Trusted Types policy also cover the SRI-pinned CDN bundle?

Yes, but at a different moment. SRI decides at fetch time whether those bytes are allowed to execute at all; Trusted Types decides at sink time whether any executing script, first-party or vendor, may hand a raw string to innerHTML or a script src. A CDN bundle that passes its sha384 check still has to route sink assignments through the default policy, so an intentionally malicious but correctly hashed release cannot write markup unchecked.

What actually happens when a proxy truncates the CSP header?

The browser discards the malformed header, so every directive it carried vanishes at once: inline scripts run without a nonce check, DOM sinks accept raw strings again, and no violation reports are sent. Only SRI survives, because integrity attributes live in the markup rather than the header. The page keeps rendering normally, which is why header length belongs in a CI assertion rather than a manual review.

Permalink to "Related"

Related Articles

Combining require-sri-for with CSP
Coordinating SRI, CSP & Trusted Types Runtime Policy Enforcement & T…