A bundled app under a nonce-based Content-Security-Policy boots fine, but every lazily loaded chunk it injects at runtime is blocked. Why does the entry script pass while its children fail, and what are the two ways to fix it?
answer
- created by script, not by the parser
- the new element has no attributes you did not set
- trust can propagate instead of being listed
- the keyword that voids the allowlist
- innerHTML stays blocked either way
basics
~20 sScripts created with document.createElement carry no nonce, so a nonce-only policy blocks them. Either propagate the nonce onto each injected element (bundlers expose a hook for this), or add 'strict-dynamic', which extends trust from an already-trusted script to the ones it creates.
solid answer
~50 sThe entry bundle was written into the HTML by your server with the nonce stamped on it. The chunks are not: the bundler's runtime does `document.createElement('script')`, sets `src`, and appends it, and that fresh element has no nonce attribute, so a policy of `script-src 'nonce-…'` refuses it. Two fixes. First, propagate the value — read it as `document.currentScript.nonce` from the entry and assign it to each injected element; webpack does this for you if you set the `__webpack_nonce__` global. Second, add `'strict-dynamic'`, a CSP Level 3 source expression that says: a script the browser already trusts may create further scripts programmatically, and those inherit the trust. Note `'strict-dynamic'` also makes supporting browsers ignore host allowlists, `'self'` and `'unsafe-inline'` in that same directive, and it deliberately does not cover parser-inserted markup such as a script written via `innerHTML` or `document.write`.
code
javascript · 14 lines// Entry bundle, executing as a classic script.
// getAttribute('nonce') returns "" because of nonce hiding; read the IDL property.
const nonce = document.currentScript.nonce;
// webpack stamps this on every <script>/<link> it injects for a lazy chunk
__webpack_nonce__ = nonce;
// The hand-rolled equivalent for a script you inject yourself
function loadScript(src) {
const s = document.createElement('script');
s.nonce = nonce;
s.src = src;
document.head.appendChild(s);
}go deeper
Know that a script created with document.createElement starts with no nonce attribute, so a nonce-based policy blocks it until you set one.
Be able to explain the parser-inserted versus programmatically created distinction, and show the propagation fix including why the value must be read as element.nonce.
Demonstrate the deployment judgment: when propagation is feasible, when a chain of third-party loaders forces 'strict-dynamic', and what that keyword silently does to the rest of the directive.
Own the policy shape for a page you do not fully control — how much third-party script you accept, whether trust propagation or an enumerated allowlist is the maintainable boundary, and what each costs the teams shipping tags.
## The two categories the browser distinguishes CSP treats script elements differently depending on how they entered the document. - **Parser-inserted**: the HTML parser created it, from the response body or from `document.write`. Its nonce attribute is whatever the markup said. - **Non-parser-inserted**: script created it, typically `document.createElement('script')` followed by an append. Whatever you did not set is not there — including the nonce. A bundler's lazy-loading runtime lives squarely in the second category. When your code hits a dynamic `import()` or a route-level split point, the runtime builds a script element for the chunk URL and appends it to `<head>`. Under `script-src 'nonce-abc'`, that element matches no source expression, so the load is refused and your route never renders. The symptom is characteristic: the shell boots, the first paint looks right, and navigation dies with console violations naming the chunk URL. ## Fix one: propagate the nonce The nonce is available at runtime from the element that is currently executing: ```js // inside the entry, executing as a classic script const nonce = document.currentScript.nonce; ``` Remember nonce hiding — `getAttribute('nonce')` returns an empty string, so the IDL property is the only read that works. In a module script `document.currentScript` is null, so the usual pattern is to render the value into a `data-` attribute on a known element (or into a global) and read it from there. Webpack formalises this: assign the global `__webpack_nonce__` early in the entry and its runtime stamps that value on every `<script>` and `<link>` element it injects for a chunk. ```js __webpack_nonce__ = document.currentScript.nonce; ``` This approach keeps the policy strictly nonce-based, which is easy to reason about. Its cost is plumbing: every library that injects scripts or stylesheets of its own — tag managers, CSS-in-JS runtimes, third-party widgets — needs its own nonce hook, and many do not have one. ## Fix two: 'strict-dynamic' `'strict-dynamic'` is a CSP Level 3 source expression that changes the trust model from *enumerating URLs* to *propagating trust*: ```http Content-Security-Policy: script-src 'nonce-abc' 'strict-dynamic'; object-src 'none'; base-uri 'none' ``` A script that the policy already trusts — one carrying a valid nonce or hash — may create further script elements programmatically, and those execute without a nonce of their own. Trust flows transitively down the chain of programmatic creation, so the chunk your bundler injects is allowed because the runtime that injected it was allowed. Three consequences matter in practice: 1. **Host allowlists in that directive stop applying.** In a supporting browser, `'strict-dynamic'` makes `'self'`, `'unsafe-inline'` and every URL expression in the same `script-src` be ignored. That is intentional — it removes the weak, bypass-prone part of the policy — but it surprises teams who expect their CDN entry to keep working alongside it. Everything must now trace back to a nonced root. 2. **Parser-inserted script is still blocked.** Markup injected through `innerHTML` or `document.write` is not covered, which is precisely the sink an XSS uses. That asymmetry is the whole point: your loader keeps working, an injection does not. 3. **Older browsers ignore the keyword.** A parser that does not understand `'strict-dynamic'` skips the unknown expression and enforces the rest of the directive, which is why the deployment guidance is to keep a nonce alongside it — the nonce is what those browsers fall back to. `'strict-dynamic'` governs `script-src` only. Styles, images and frames are unaffected, and a CSS-in-JS runtime injecting `<style>` elements still needs its own answer under `style-src`. ## Choosing between them If you control every injection site and the app is largely first-party, propagation is tidier and keeps the policy legible. If the page carries third-party tags that load further tags of their own — the classic loader-loads-a-loader shape — propagation is unwinnable and `'strict-dynamic'` is the mechanism designed for it. The two combine cleanly: propagate where you can, and let `'strict-dynamic'` cover the rest. ## How this actually fails in review The common bad fix is to notice the blocked chunk and add the CDN host, or `'self'`, to `script-src`. Under `'strict-dynamic'` that has no effect at all, so the team then removes `'strict-dynamic'` and ends up with an allowlist policy — weaker than what they started with, and now maintained by hand.
- After adding 'strict-dynamic', a colleague appends your CDN host to the same script-src to fix a blocked load. What happens?Nothing — in a browser that understands `'strict-dynamic'`, every URL expression, `'self'` and `'unsafe-inline'` in that directive is ignored. The load must instead trace back to a nonced or hashed script that created it. Adding hosts only misleads the next reader and tempts the team into removing `'strict-dynamic'` and reverting to a weak allowlist.
- Does 'strict-dynamic' let a script inject markup containing a script tag through innerHTML?No, and that exclusion is the point. Trust propagates only to programmatically created script elements, not to parser-inserted ones, so `innerHTML` and `document.write` remain blocked. Since injected markup is exactly how XSS delivers a script, propagating trust there would defeat the policy entirely.
- Your CSS-in-JS runtime injects style elements and they are now blocked. Does 'strict-dynamic' help?No — `'strict-dynamic'` applies to `script-src` only. Styles need their own answer under `style-src`: pass the nonce to the library's documented nonce option so its injected `<style>` elements carry it, or move the generated CSS to a build-time stylesheet served from an allowed origin.
saying these in an interview costs you the question
- Thinks a dynamically created script inherits the page's nonce
- Adds the CDN host to script-src alongside 'strict-dynamic'
- Believes 'strict-dynamic' also unblocks innerHTML script
- Expects 'strict-dynamic' to cover style-src too
- Drops the nonce once 'strict-dynamic' is present