Dynamic script injection triggered by a consent manager fails silently under strict Content Security Policy: the browser logs Refused to execute script because it violates the following Content Security Policy directive: "script-src" and drops the pixel or analytics tag without any visible UI error.

Triage Workflow: Isolating the Injection Failure

Work through these steps in order before writing a single line of fix code.

The failure is almost always one of two patterns: the dynamically injected <script> element has no nonce attribute matching the page’s current nonce, or the nonce was cached at the CDN edge so the HTML and the response header contain different values.


CSP nonce flow: per-request generation to consent-gated injection Server generates a fresh nonce per request and sets it in the CSP header and a meta tag. The CDN must not cache the response. The browser's consent manager reads the nonce from the meta tag and applies it to dynamically injected scripts, which 'strict-dynamic' then trusts. Origin Server nonce = randomBytes(16) CDN Edge Cache-Control: no-cache Browser parses CSP header Consent Manager reads meta[csp-nonce] Dynamic <script> script.nonce = nonce ✓ pass-through HTML + header 'strict-dynamic' propagates trust: child scripts appended by the nonced loader are automatically trusted — no per-child nonce required

Root Cause: Static Nonce Mismatch and Missing strict-dynamic

The precise mechanism is explained fully in the implementing strict Content Security policies cluster, but the failure reduces to two compounding problems:

Problem 1 — no nonce on injected elements. Browsers evaluate CSP at parse time for elements in the initial HTML. Post-render document.createElement('script') calls create elements that never passed through the parser, so they receive no implicit trust. They need an explicit nonce attribute matching the current page’s nonce, or they need to be appended by a script that already holds that nonce (the 'strict-dynamic' mechanism).

Problem 2 — nonce reuse via CDN caching. If a CDN edge caches the full HTML response — including the nonce embedded in both the CSP header and the meta tag — every visitor for that cache TTL receives the same nonce. The nonce in the next request’s Content-Security-Policy response header will differ from the stale nonce in the cached HTML, producing a permanent mismatch until the CDN invalidates. The fix is Cache-Control: private, no-cache on every page that embeds a nonce.

The consent-state machine fires its consent:accepted event after the initial parse — always in post-render territory — which is exactly the window where nonce validation is strictest and the error is most likely.

Resolution Path: Per-Request Nonce with strict-dynamic Propagation

The minimal complete fix has three parts: server middleware, a meta tag in the HTML template, and a nonce-aware consent loader on the client.

Step 1 — Server middleware (Express)

const crypto = require('crypto');

app.use((req, res, next) => {
  // Generate a fresh 128-bit nonce for every HTTP response
  const nonce = crypto.randomBytes(16).toString('base64');

  // Make the nonce available to SSR templates
  res.locals.cspNonce = nonce;

  res.setHeader(
    'Content-Security-Policy',
    // 'nonce-...' authorises the loader script embedded in the HTML
    // 'strict-dynamic' propagates trust to any script appended by that loader
    `default-src 'self'; ` +
    `script-src 'nonce-${nonce}' 'strict-dynamic'; ` +
    `object-src 'none'; ` +
    `base-uri 'self'; ` +
    `report-uri /csp-violation-report`
  );

  // Pages with per-request nonces must never be served from CDN cache
  res.setHeader('Cache-Control', 'private, no-cache, max-age=0');

  next();
});

Step 2 — Embed the nonce in the HTML template

<!-- Rendered server-side; the nonce value must match the CSP header exactly -->
<meta name="csp-nonce" content="{{ cspNonce }}">

<!-- The consent loader script itself carries the nonce so it is trusted -->
<script src="/js/consent-loader.js" nonce="{{ cspNonce }}" defer></script>
// consent-loader.js — served from your own origin, nonced by the server
const consentLoader = {
  // Read the nonce the server embedded in the meta tag.
  // document.currentScript.nonce would also work here while this script executes,
  // but the meta tag survives for later async callbacks.
  nonce: document.querySelector('meta[name="csp-nonce"]')?.content ?? '',

  inject(src, onLoad) {
    if (!this.nonce) {
      // Missing nonce means the CSP header and meta tag are out of sync —
      // log and bail rather than silently create a blocked element.
      console.warn('[CSP] nonce unavailable — script injection skipped:', src);
      return;
    }

    const script = document.createElement('script');
    script.src   = src;
    script.nonce = this.nonce;  // Must match the value in the CSP header
    script.async = true;

    script.onload  = () => typeof onLoad === 'function' && onLoad();
    script.onerror = () => console.error('[CSP] script blocked or failed:', src);

    document.head.appendChild(script);
    // Because this loader itself is nonced and 'strict-dynamic' is set,
    // any script that *this* script subsequently appends is also trusted —
    // you do not need to nonce those grandchild scripts individually.
  }
};

window.addEventListener('consent:accepted', () => {
  consentLoader.inject('https://pixel.example.com/init.js', () => {
    console.log('[CSP] pixel hydrated');
  });
});

For static sites (no server-side nonce generation). Use a hash-based policy instead. Pre-compute sha256 of every known inline script, add each as 'sha256-{base64hash}' in the header, and combine with 'strict-dynamic' so dynamically appended scripts still propagate trust:

# nginx — hash list must be regenerated every time an inline script changes
add_header Content-Security-Policy
  "default-src 'self'; script-src 'sha256-{PRECOMPUTED_HASH}' 'strict-dynamic'; object-src 'none'; report-uri /csp-violation-report;"
  always;

Verification: Confirming Zero CSP Violations

After deploying, run this sequence:

  1. Hard-reload the page (Shift+Reload) to bypass browser cache and receive a fresh nonce.
  2. In the Console, verify document.querySelector('meta[name="csp-nonce"]')?.content returns a non-empty Base64 string.
  3. In the Network tab, confirm the Content-Security-Policy response header contains the same nonce- value you see in the meta tag.
  4. Click the CMP accept button and watch the Console. Zero Refused to execute errors is the pass condition.
  5. Run performance.getEntriesByType('resource').filter(r => r.name.includes('pixel')) — entries for every expected pixel URL must now be present.
  6. Optional: submit the production URL to csp-evaluator.withgoogle.com in CI to catch regressions automatically.

It is worth being precise about which injection styles carry the nonce and which do not, because the distinction is invisible in a code review that is looking for “does it set the nonce”. The three cases below behave differently for three unrelated reasons — one is a policy rule, one is an HTML parsing rule that predates content security policies entirely, and one is simply the absence of a parent to inherit from.

Which injections inherit trust Three injection styles. Creating an element with createElement and appending it from a trusted script inherits trust under strict-dynamic and needs no nonce. Setting innerHTML with script markup does not execute at all, by specification, regardless of policy. Writing a script element into the initial HTML from the server requires the response nonce explicitly, because it has no trusted parent script to inherit from. createElement + append inherits trust from the script that created it no nonce needed innerHTML = "<script…" never executes, policy or not — this is HTML spec behaviour not a policy problem to debug server-rendered <script> has no trusted parent to inherit from must carry the nonce Only the right-hand case needs the nonce written into markup — and only the roots, not the vendors they load. Adding nonces to vendor URLs you do not control is the most common wasted effort in a strict-dynamic rollout.

Common Pitfalls

  • Caching pages with embedded nonces at the CDN. The Cache-Control: private, no-cache directive on the server response is not enough if a CDN is configured to ignore origin cache headers. Explicitly configure the CDN to never cache HTML responses, or use edge-side nonce generation (e.g., a Cloudflare Worker that rewrites the nonce per request before forwarding). This pairs well with offloading heavy scripts to Web Workers at the edge.

  • Adding 'unsafe-inline' as a fallback alongside the nonce. In nonce-supporting browsers, 'unsafe-inline' is silently ignored — the nonce takes full precedence. In older browsers that do not understand nonces, 'unsafe-inline' becomes the entire effective policy, defeating the security guarantee entirely. Remove it from script-src unconditionally.

  • Failing to attach a report-uri or Report-To endpoint. Without a violation reporting endpoint, production CSP failures are invisible until a user reports a broken marketing pixel. Even a simple endpoint that logs structured JSON is sufficient to catch nonce mismatches caused by CDN configuration drift or framework version upgrades that alter inline script content.

Trust propagates from the script, not from the origin Under strict-dynamic, a script carrying the response's nonce is trusted, and any script it creates programmatically inherits that trust regardless of origin, as does anything those scripts create in turn. A script whose nonce does not match the current response is blocked, and nothing it would have loaded is reached. A note records that a nonce baked into a cached page is a nonce from a previous response, which is why static caching and nonces are incompatible without edge substitution. nonce matches loader.js — trusted vendor-a.js no nonce needed vendor-a-dep.js trust keeps propagating all execute nonce stale from a cached response blocked — and nothing downstream is reached the console names only the first script, not the vendors it would have loaded A nonce in a cached page is a nonce from someone else's response — substitute it at the edge or do not cache. This is why the symptom is "one script blocked" while the effect is "the whole tag stack missing".

Frequently Asked Questions

Can a nonce policy work behind a full-page CDN cache?

Only if the edge rewrites the nonce per response. A nonce baked into a cached HTML body is, by definition, a value generated for a different request — every subsequent visitor receives a token that does not match their response header, so every nonce-bearing script is blocked. The failure is total and looks like a broken deploy rather than a caching problem.

Most modern edge platforms can do the substitution: cache the body with a placeholder, generate a fresh value at the edge, and write it into both the header and the body on the way out. Where that is not available, hashes are the workable alternative for static inline scripts, with the trade-off that every content change requires the hash to be regenerated.

Why does the violation report name a script we did not add?

Because the blocked resource is usually a dependency, not the thing you configured. A tag container loads vendors, and those vendors load their own dependencies; when trust does not propagate, the report names whichever script in that chain the browser refused, which is frequently several levels below anything in your source.

Read the report’s document-uri and blocked-uri together and work upward: find which of your own scripts should have been the trusted root for that chain. The fix is almost always at the root — a missing nonce, or an injection path that sets innerHTML instead of creating an element — rather than at the script the report names.


Up: Implementing Strict Content Security Policies