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:

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

strict-dynamic trust propagation A nonced root script is trusted by the policy; under strict-dynamic the scripts it loads inherit that trust, while a script that the page itself injects without the nonce is blocked. Root script nonce-X trusted Loaded child A inherits trust Loaded child B inherits trust Injected, no nonce blocked
Trust flows from the nonced root script to the scripts it loads; markup or attacker-injected scripts without that lineage are still blocked.

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.

How CSP1, CSP2, and CSP3 browsers interpret each source expression A matrix showing that older browsers honour the host-list fallbacks while only CSP3 browsers honour strict-dynamic and discard the fallbacks. Browser 'strict-dynamic' nonce / hash https: 'unsafe-inline' CSP1 legacy ignored ignored honored honored CSP2 nonce-aware ignored honored honored ignored CSP3 strict honored honored ignored ignored
The same header degrades by generation: CSP3 enforces the nonce and propagation while discarding the host list, whereas older engines fall back to 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:

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.

Decision tree for whether a script executes under strict-dynamic A flowchart tracing a script from nonce check, to trusted-inserter check, to the document.write exclusion, ending in allowed or blocked outcomes. Script attempts to run Carries nonce or hash? Allowed entry point Inserted by a trusted script? Blocked no trust lineage Inserted via document.write? Blocked parser-inserted Allowed inherits trust yes no no yes yes no
Every script resolves to allowed or blocked along this path: only a nonced entry point, or a script programmatically inserted by an already-trusted script, executes.

Edge Cases, Security Implications & Safe Rollback

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.