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.

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.


Up: Implementing Strict Content Security Policies