CSP strict-dynamic explained
This guide is part of the Content-Security-Policy (CSP) reference and covers the 'strict-dynamic' source expression — the keyword that turns a nonce or hash allowlist into a self-propagating trust chain. With 'strict-dynamic' in script-src, a script you explicitly trust (via its nonce or hash) is allowed to load further scripts, and those inherit the trust without needing to be on any host allowlist. This is the foundation of a CSP3 “strict” policy: instead of maintaining a brittle list of CDN hostnames that an attacker can often abuse for bypass, you trust one root script and let it vouch for what it pulls in.
Configuration Syntax & Exact Values
The canonical CSP3 strict policy combines 'strict-dynamic', a nonce (or hash), and two backwards-compatibility fallbacks:
Content-Security-Policy: script-src 'strict-dynamic' 'nonce-r4nd0mBase64Value' https: 'unsafe-inline'; object-src 'none'; base-uri 'self'
Annotated breakdown — note that the tokens deliberately mean different things to different browser generations:
'strict-dynamic'— tells a CSP3-aware browser to grant trust to any script loaded by an already-trusted (nonced or hashed) script, and to ignore the host-source allowlist (https:, domain names) and'unsafe-inline'entirely. Trust flows from the root script outward through DOM-inserted<script>elements.'nonce-…'(or a'sha256-…'hash) — the entry point. With'strict-dynamic'present, only scripts carrying this nonce/hash, plus whatever those scripts load, can execute. This is mandatory:'strict-dynamic'with no nonce or hash trusts nothing.https:— a host-source fallback. CSP3 browsers ignore it (because'strict-dynamic'is present); older CSP2 browsers, which do not understand'strict-dynamic', ignore that keyword and fall back to honoringhttps:, so scripts still load there.'unsafe-inline'— another fallback that CSP3 browsers ignore in the presence of a nonce/'strict-dynamic', but that CSP1 browsers (which understand neither nonces nor'strict-dynamic') fall back to so inline scripts keep running.
The result is one policy that is strict on modern browsers and gracefully degrades on old ones — modern engines enforce nonce + propagation, older engines enforce https: + 'unsafe-inline'.
How each browser generation reads the same policy
The single header above is really three policies wearing one coat. Each source expression is a signal that some browser generations understand and others silently drop, and the design deliberately exploits that. A CSP1 engine predates nonces entirely, so it ignores both the nonce and 'strict-dynamic' and enforces only the host-source keywords — including 'unsafe-inline', which keeps inline scripts running there. A CSP2 engine understands nonces (and, per spec, ignores 'unsafe-inline' once a nonce is present) but treats 'strict-dynamic' as an unknown token, so it enforces the nonce and the https: host list. Only a CSP3 engine acts on 'strict-dynamic', at which point the host list and 'unsafe-inline' are both discarded in favour of nonce-seeded propagation. The matrix below shows exactly which token each generation honours versus ignores.
https: and 'unsafe-inline'.Server-Side Configuration
The header is static apart from the nonce, which must be generated per request (see generating CSP nonces per request). The placeholder 'nonce-…' below stands for that per-response value.
Nginx
add_header Content-Security-Policy "script-src 'strict-dynamic' 'nonce-$request_id' https: 'unsafe-inline'; object-src 'none'; base-uri 'self'" always;
always emits the header on error responses too. $request_id is a stopgap; prefer an application-generated cryptographic nonce as described in the noncing guide, and mark these responses uncacheable.
Apache
Header always set Content-Security-Policy "script-src 'strict-dynamic' 'nonce-%{REQUEST_NONCE}e' https: 'unsafe-inline'; object-src 'none'; base-uri 'self'"
Header always set attaches the header across all status codes; requires mod_headers. %{REQUEST_NONCE}e reads an environment variable your app sets per request — the nonce must come from the application layer, not a static config value.
Node/Express (Helmet)
const crypto = require('crypto');
const helmet = require('helmet');
app.use((req, res, next) => {
res.locals.nonce = crypto.randomBytes(16).toString('base64');
next();
});
app.use(helmet.contentSecurityPolicy({
useDefaults: false,
directives: {
scriptSrc: [
"'strict-dynamic'",
(req, res) => `'nonce-${res.locals.nonce}'`,
"https:",
"'unsafe-inline'",
],
objectSrc: ["'none'"],
baseUri: ["'self'"],
},
}));
The scriptSrc function is evaluated per response so the nonce matches the value rendered into the root <script nonce="…">. Order does not affect enforcement, but keep 'strict-dynamic' and the nonce together for readability.
Diagnostic & Verification Steps
Confirm the policy ships, then check that propagation works and that the fallbacks are present.
curl -sI https://example.com/ | grep -i content-security-policy
Expected output:
content-security-policy: script-src 'strict-dynamic' 'nonce-Yz2bQk9...' https: 'unsafe-inline'; object-src 'none'; base-uri 'self'
Paste the policy into Google’s CSP Evaluator (csp-evaluator.withgoogle.com). A correct strict policy scores well and flags https:/'unsafe-inline' as ignored on modern browsers (the intended fallback), rather than as live weaknesses. The Evaluator warns loudly if the nonce or hash is missing — that is the failure to fix, because 'strict-dynamic' without an entry point trusts nothing.
To prove propagation directly rather than trusting the policy on paper, add a trusted loader to your nonced root script and watch a third-party script it inserts execute even though its host appears on no allowlist:
// Runs inside the nonced root <script>, which is already trusted.
const s = document.createElement('script');
s.src = 'https://cdn.example-analytics.com/tag.js'; // host not allowlisted
s.onload = () => console.log('propagated: child ran');
document.head.appendChild(s);
Expected console output on a CSP3 browser is propagated: child ran, with no CSP violation logged — the child inherited trust from the root via programmatic insertion. Now change the same test to document.write('<script src="…"></script>') and reload: the child is refused and the console prints a script-src violation, confirming the document.write exclusion is active. This before/after pair is the most reliable signal that 'strict-dynamic' is genuinely enforced and not merely present in the header string.
In the browser DevTools → Console, expected behavior:
- The nonced root script runs, and any script it loads via
document.createElement('script')/ a dynamic loader runs too — no host allowlist needed. - A script the page markup injects without the nonce is blocked, with
Refused to load the script … because it violates the … Content Security Policy directive: "script-src 'strict-dynamic' 'nonce-…'". - A
document.write('<script…>')call is blocked (see below).
Deciding whether a given script runs
When a script fails to run under a strict policy, the fastest way to diagnose it is to trace the browser’s decision path rather than guessing at the header. The engine asks a short series of questions for every script it encounters. First, does the element carry the current nonce or match a listed hash? If so it is an entry point and runs directly. If not, was it inserted into the DOM by a script that is already trusted? Only then can 'strict-dynamic' extend trust to it — and only if the insertion used a programmatic API (document.createElement('script') followed by appendChild or insertBefore, or assigning .src). A parser-driven document.write() is treated as untrusted even when the caller is trusted, because the token stream it injects cannot be attributed to a specific trusted element. Anything that reaches the parser without a nonce and without a trusted programmatic inserter is refused. Walk the tree below against the failing script and the blocked branch will name the cause.
Edge Cases, Security Implications & Safe Rollback
- Older browsers fall back to the host allowlist. CSP2 browsers ignore
'strict-dynamic'and enforcehttps:instead; CSP1 browsers ignore the nonce too and fall back to'unsafe-inline'. This is intentional graceful degradation, but it means old clients get the weaker host-based policy. Keep thehttps:and'unsafe-inline'fallbacks unless your audience is exclusively modern browsers, in which case dropping them tightens the policy. document.writeof scripts is blocked.'strict-dynamic'propagates trust through DOM-inserted (appendChild/insertBefore)<script>elements, but parser-inserted scripts viadocument.write()are explicitly not trusted and will not execute. Migrate anydocument.write-based loaders to programmatic insertion.- The entry script must carry a nonce or hash.
'strict-dynamic'is inert without at least one'nonce-…'or'sha256-…'source. If your root script is dynamically generated, use a nonce (per-request nonces); if it is static and build-stable, a hash works as the entry point too. - Trust really does propagate. Because the root script can load anything, a vulnerability in that trusted script (an XSS sink it exposes, an untrusted loader it calls) extends to everything it pulls in.
'strict-dynamic'removes host-allowlist bypasses but does not absolve the entry script of being secure. - Scope is
script-srconly, and modules follow the parser rules.'strict-dynamic'governs script sources exclusively — it has no effect onstyle-src,img-src,connect-src, orworker-src, which still need their own explicit sources. Statically parsed ES module imports (<script type="module">and its staticimportstatements) are treated as loaded by the trusted module, so they inherit trust; a dynamicimport()called from trusted code inherits trust the same way. If a loader assigns the URL through a trusted script but the browser attributes the fetch to the parser (as withdocument.write), the module is refused just like a classic script.
Rollback (reversible): the change is header-only and non-destructive. To revert, drop 'strict-dynamic' and return to your prior explicit script-src host allowlist (plus the nonce/hash), then reload the service. Browsers immediately resume enforcing the host list.
# Revert: remove 'strict-dynamic', restore the explicit host allowlist, then:
# nginx -t && systemctl reload nginx
Frequently Asked Questions
Why are https: and 'unsafe-inline' in a “strict” policy?
They are backwards-compatibility fallbacks, not active permissions on modern browsers. A CSP3 browser sees 'strict-dynamic' and a nonce and ignores both https: and 'unsafe-inline'. Older browsers that do not understand 'strict-dynamic' (or nonces) fall back to honoring them, so scripts still load there. The policy is strict where it can be and degrades gracefully where it cannot.
Does 'strict-dynamic' mean I no longer need a nonce?
No — the opposite. 'strict-dynamic' is inert without a nonce or hash to seed trust. You still generate a per-request nonce (or use a build-time hash) for the root script; 'strict-dynamic' only governs how that trust spreads to the scripts the root loads.
Why is my dynamically loaded script still blocked?
Two common causes: the loader uses document.write, which 'strict-dynamic' does not trust — switch to document.createElement('script') plus appendChild; or the chain breaks because an intermediate script was injected without the nonce and could not become trusted in the first place. Trust only propagates from an already-trusted script.
Can I use 'strict-dynamic' with hashes instead of nonces?
Yes. A 'sha256-…' hash of the inline root script works as the entry point exactly like a nonce, which is useful for static, cacheable pages with no per-request render step. Everything the hashed root then loads inherits trust the same way.
Conclusion
Roll 'strict-dynamic' out incrementally. Deploy the strict policy in Content-Security-Policy-Report-Only on staging first so any blocked loader or document.write surfaces as a report instead of breakage, validate it in CSP Evaluator and confirm dynamically loaded scripts run, then promote to enforcing mode on production while keeping the https:/'unsafe-inline' fallbacks for older clients until your traffic no longer needs them.
Related
- Content-Security-Policy (CSP) reference — the parent header reference and Report-Only rollout.
- Generating CSP nonces per request — the per-response nonce that seeds the trust chain.
- Hash-based CSP for inline scripts — using a static hash as the strict-dynamic entry point.