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
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.
<!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.
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 — 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
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.
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.
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
- Layering CSP Nonces, SRI and Trusted Types — the architecture view of these same three layers, including how they compose across multiple apps