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.
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>
Step 3 — Nonce-aware consent loader (client)
// 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:
- Hard-reload the page (Shift+Reload) to bypass browser cache and receive a fresh nonce.
- In the Console, verify
document.querySelector('meta[name="csp-nonce"]')?.contentreturns a non-empty Base64 string. - In the Network tab, confirm the
Content-Security-Policyresponse header contains the samenonce-value you see in the meta tag. - Click the CMP accept button and watch the Console. Zero
Refused to executeerrors is the pass condition. - Run
performance.getEntriesByType('resource').filter(r => r.name.includes('pixel'))— entries for every expected pixel URL must now be present. - 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.
Common Pitfalls
-
Caching pages with embedded nonces at the CDN. The
Cache-Control: private, no-cachedirective 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 fromscript-srcunconditionally. -
Failing to attach a
report-uriorReport-Toendpoint. 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.
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.