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
<!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.

Variant Examples

Permalink to "Variant Examples"

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.
  • 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.

Permalink to "Related"

Related Articles

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