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.
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 anintegrityattribute but nocrossorigintriggers a no-CORS fetch, the browser gets an opaque response it cannot hash, and the integrity gate never runs. Always pairintegritywithcrossorigin="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 effectivelyunsafe-inline, and an attacker who reads the page can reuse the value. Mark nonce-bearing HTMLCache-Control: no-storeor 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 inContent-Security-Policy-Report-Onlyfirst, collect the violated sinks, wrap them in thedefaultpolicy, then enforce. - Registering the Trusted Types policy too late still throws. The
defaultpolicy must be created before any code path touches a sink. If a bundled library assignsinnerHTMLduring 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-Policyis 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.
Related
Permalink to "Related"- Coordinating SRI, CSP & Trusted Types — the layered rollout and full threat-to-layer matrix this response implements
- Combining require-sri-for with CSP — the source-plus-integrity half, including the honest
require-sri-forsupport story - Configuring Content Security Policy with SRI — the CSP-and-SRI reference underpinning the nonce policy here