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'.
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.
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).
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.
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.