A browser has no built-in concept of trusted versus untrusted script execution — by default, every <script> tag on the page runs with the same authority. Content Security Policy (CSP) fills that gap: it is the declarative firewall that tells the browser which origins, tokens, and execution contexts are permitted before a single byte of script is evaluated. When configured correctly, strict CSP eliminates unauthorized inline execution, closes the XSS attack surface exploited by compromised vendor SDKs, and provides a mandatory enforcement hook for consent-gated script loading.

This guide covers the complete engineering path from directive design through production rollout, including nonce lifecycle management, iframe layering, Web Worker allowances, violation reporting, and compliance workflows.

Prerequisites and When to Apply Strict CSP

Use this approach when all of the following are true:

  • Your site loads one or more cross-origin vendor scripts (analytics, A/B testing, chat widgets, payment SDKs).
  • You operate under GDPR, CCPA, or an equivalent regulation that requires provable consent gating before script execution.
  • You need a machine-enforceable record of which origins executed JavaScript — for compliance audits or incident response.

What you need before starting:

  • Server-side request handling (Node.js, Edge Workers, or any backend capable of setting response headers per request).
  • An SSR template system that can receive and render a per-request nonce value.
  • A 7–14 day window to run Content-Security-Policy-Report-Only and collect baseline violations before switching to enforcement.

If you’re running a fully static site served from an edge CDN with no per-request header control, see Setting up CSP headers for dynamic script injection for edge-side nonce injection patterns.


CSP Enforcement Flow Diagram showing how a browser request triggers server-side nonce generation, the nonce is embedded in the HTTP header and HTML template, and the browser uses it to allow or block script execution. Browser GET /page Server randomBytes(16) → nonce token CSP header + HTML nonce in header + DOM Browser CSP Engine nonce match? Allow script executes Block violation reported CMP Consent Gate gates nonce issuance

The Browser Mechanics: What CSP Actually Enforces

The Content-Security-Policy HTTP header is parsed before the HTML parser begins tokenizing the document. Every <script> element encountered during parsing is evaluated against the compiled directive set before execution is permitted.

Directive hierarchy the browser uses:

  1. script-src-elem — evaluated first for <script src="..."> and inline <script> blocks.
  2. script-src-attr — evaluated for inline event handlers (onclick, onload, onerror attributes).
  3. script-src — fallback for any script context not covered by the above.
  4. default-src — fallback for all fetch directives not explicitly set.

This specificity order matters in practice: setting only script-src still allows inline event handlers in browsers that do not implement script-src-attr. A strict policy explicitly sets both script-src-elem (with nonce) and script-src-attr 'none'.

How 'strict-dynamic' changes the trust model:

Without 'strict-dynamic', every <script> element must be individually nonced or hashed. With it, trust is delegated: any script injected by a nonced parent script inherits execution permission automatically. This is the correct mechanism for dynamically loading consent-gated third-party scripts — you nonce the consent loader, and it injects vendor scripts that are trusted transitively.

Critical constraint: 'unsafe-inline' must not appear alongside 'nonce-...'. In nonce-aware browsers it is ignored; in older browsers it becomes the entire operative policy, defeating the directive entirely.


Implementation: Step-by-Step

Step 1 — Run in Report-Only Mode to Build Your Allowlist

Before writing a single enforcement header, deploy Content-Security-Policy-Report-Only with a permissive policy and a reporting endpoint:

Content-Security-Policy-Report-Only: default-src 'self';
  script-src 'nonce-PLACEHOLDER' 'strict-dynamic';
  object-src 'none';
  base-uri 'self';
  report-uri https://csp-collector.yoursite.com/api/v1/violations;
  reporting-endpoints csp-endpoint="https://csp-collector.yoursite.com/api/v1/violations"

Collect violations for 7–14 days. The violation payload includes blocked-uri, violated-directive, and source-file — use these to build the definitive origin allowlist before enforcement.

Note: The Reporting-Endpoints header and report-to CSP directive are the current Reporting API v1 mechanism. Keep report-uri as a fallback for browsers that have not shipped v1 support.


Step 2 — Generate Per-Request Nonces Server-Side

A nonce must be:

  • Cryptographically random (minimum 128 bits / 16 bytes)
  • Base64-encoded (no URL-unsafe characters)
  • Unique per HTTP response — never reused, never cached at the CDN layer
// middleware/csp.js (Node.js / Express)
const crypto = require('crypto');

function cspMiddleware(req, res, next) {
  const nonce = crypto.randomBytes(16).toString('base64');

  // Make nonce available to the template engine
  res.locals.cspNonce = nonce;

  // Build the enforcement header
  const directives = [
    `script-src-elem 'nonce-${nonce}' 'strict-dynamic' https://cdn.vendor.com`,
    `script-src-attr 'none'`,
    `object-src 'none'`,
    `base-uri 'self'`,
    `frame-src https://widget.vendor.com`,
    `worker-src 'self' blob:`,
    `frame-ancestors 'self'`,
    `form-action 'self' https://checkout.vendor.com`,
    `report-uri /api/csp-violations`,
  ].join('; ');

  res.setHeader('Content-Security-Policy', directives);

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

  next();
}

module.exports = cspMiddleware;

SSR frameworks: Pass res.locals.cspNonce through the rendering context so the template can emit <script nonce="..."> server-side. In Next.js, propagate through nonce prop on <Script> components or via a custom _document.js.


Step 3 — Replace unsafe-inline with Nonce Directives

The canonical strict policy for a site with external vendor scripts:

Content-Security-Policy:
  default-src 'self';
  script-src-elem 'nonce-{RANDOM_NONCE}' 'strict-dynamic';
  script-src-attr 'none';
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;
  font-src 'self' https://fonts.gstatic.com;
  connect-src 'self' https://api.vendor.com https://analytics.vendor.com;
  object-src 'none';
  base-uri 'self';
  frame-src https://widget.vendor.com;
  worker-src 'self' blob:;
  frame-ancestors 'self';
  form-action 'self';

Each directive is intentional:

  • script-src-attr 'none' — eliminates inline event handlers across the entire document.
  • object-src 'none' — blocks Flash and legacy plugin execution vectors.
  • base-uri 'self' — prevents <base href> injection from redirecting relative URLs to an attacker-controlled origin.
  • frame-ancestors 'self' — clickjacking defence: the page cannot be framed by external origins.

This is where CSP and consent management intersect. The application injects vendor scripts only after the consent-state machine signals approval. The nonce must be attached before DOM insertion — post-insertion nonce assignment is silently ignored by the browser.

// consent-injection.js
// Called by CMP callback after user grants consent

function injectConsentedScript(src, nonce) {
  if (!nonce) {
    // No nonce means no valid execution context — abort rather than silently fail
    console.warn('[CSP] Injection aborted: no nonce available for', src);
    return null;
  }

  const script = document.createElement('script');
  script.src = src;
  script.async = true;

  // CRITICAL: set nonce before appending to DOM
  // The browser reads nonce at parse/insertion time, not after
  script.setAttribute('nonce', nonce);

  script.addEventListener('load', () => {
    // Remove the element from the DOM after load
    // The script continues executing after removal — this reduces CSP audit surface
    script.remove();
  });

  script.addEventListener('error', (e) => {
    console.warn('[CSP] Script load failed:', src, e);
    script.remove();
  });

  document.head.appendChild(script);
  return script;
}

// Example CMP integration
window.__cmpConsentCallback = function(consentData) {
  const nonce = document.querySelector('meta[name="csp-nonce"]')?.content;

  if (consentData.analytics) {
    injectConsentedScript('https://cdn.analytics-vendor.com/tracker.js', nonce);
  }
  if (consentData.marketing) {
    injectConsentedScript('https://cdn.marketing-vendor.com/pixel.js', nonce);
  }
};

Pass the nonce to the client via a <meta name="csp-nonce" content="..."> tag rendered server-side. Do not expose it in a window global — a meta tag is readable only by same-origin scripts.


Step 5 — Add Iframe and Worker CSP Directives

CSP and the iframe sandbox attribute operate at different containment layers: CSP restricts the origin network graph; sandbox restricts DOM capability and cross-origin storage access. Layering both creates defence-in-depth for third-party widget containment.

<!-- Widget iframe with combined CSP + sandbox containment -->
<iframe
  src="https://widget.vendor.com/embed"
  sandbox="allow-scripts allow-forms"
  loading="lazy"
  title="Vendor rating widget"
  referrerpolicy="strict-origin-when-cross-origin"
  width="400"
  height="300">
</iframe>

What each sandbox token grants:

Token Capability unlocked Risk if omitted
allow-scripts JavaScript execution inside the iframe Widget is non-functional
allow-forms Form submission Checkout / search widgets break
allow-same-origin Access to parent origin’s storage Never grant — breaks isolation
allow-popups window.open() Required only for OAuth/SSO flows

For offloading analytics to Web Workers, worker-src must explicitly permit blob: when the worker is initialized from a URL.createObjectURL() call:

// Workers from blob URLs require worker-src 'self' blob: in the CSP header
const workerCode = `
  self.onmessage = function(e) {
    // Heavy analytics data processing — off the main thread
    const processed = transformEventPayload(e.data);
    self.postMessage(processed);
  };

  function transformEventPayload(data) {
    return {
      sessionId: data.sessionId,
      timestamp: Date.now(),
      events: data.events.map(ev => ({ t: ev.type, v: ev.value }))
    };
  }
`;

const blob = new Blob([workerCode], { type: 'application/javascript' });
const workerUrl = URL.createObjectURL(blob);
const analyticsWorker = new Worker(workerUrl);

// Revoke the object URL immediately — the Worker retains its own reference
URL.revokeObjectURL(workerUrl);

Step 6 — Switch from Report-Only to Enforcement

Once violation reports from Step 1 have stabilised (new violations drop to zero for trusted scripts, and all remaining violations are from blocked third-party origins you do not want), promote to enforcement:

  1. Change the header name from Content-Security-Policy-Report-Only to Content-Security-Policy.
  2. Keep report-uri and reporting-endpoints intact — enforcement violations are also reported.
  3. Monitor the SecurityPolicyViolationEvent count in your RUM tooling for the first 48 hours. A spike indicates a missed allowlist entry.

Verification Checklist


Interaction Matrix: How Strict CSP Interacts with Sibling Patterns

Pattern Interaction with CSP Required configuration
Iframe sandboxing CSP frame-src controls which origins can be framed; sandbox controls what the framed content can do Both must be set — frame-src without sandbox still allows storage access
Web Worker offloading Workers require worker-src allowance; blob-URL workers additionally require blob: in worker-src Set worker-src 'self' blob: when using URL.createObjectURL
postMessage cross-domain comms CSP does not restrict postMessage content, but frame-ancestors limits which origins can frame your page Always validate event.origin in message event listeners — CSP alone is not sufficient
Consent-state gating Nonce issuance is the consent enforcement point — no nonce means no execution The CMP callback must retrieve the nonce before calling the injection function
Preload / prefetch for third-party scripts <link rel="preload"> fetches are governed by the script-src allowlist when as="script" is set Preloaded script origins must appear in script-src-elem

Troubleshooting: Named Failure Modes

Failure 1 — Refused to execute inline script because it violates the following Content Security Policy directive

Cause: An inline <script> block or <script src="..."> element exists without a matching nonce or hash. Common culprits: inline GTM bootstrap snippets, SSR framework hydration scripts, or A/B test snippet injection.

Fix: Add nonce="${cspNonce}" to the offending <script> tag in your SSR template. For third-party inline snippets (GTM, Hotjar), wrap them inside a nonced loader script that then calls the vendor’s external SDK.


Failure 2 — Nonces Shared Across Users (CDN Caching Breach)

Symptom: Multiple users receive the same HTML with the same nonce value — visible in CDN access logs as the same nonce= string appearing in responses to different IPs.

Cause: A CDN edge node cached an HTML response containing the nonce. Subsequent users receive the stale copy with an expired or shared nonce.

Fix: Set Cache-Control: private, no-cache on all nonced HTML responses. Alternatively, use an edge-side injection pattern (Cloudflare Workers with HTMLRewriter) to inject nonces into a cached HTML shell at the edge, so the cache stores the shell without a nonce and the nonce is injected per-request at the edge.


Failure 3 — worker-src Violation Blocking Analytics Worker

Symptom: Refused to create a worker from 'blob:https://yourdomain.com/uuid' in the console.

Cause: blob: is absent from the worker-src directive. This affects any worker initialised via URL.createObjectURL().

Fix: Add blob: to worker-src: worker-src 'self' blob:;


Failure 4 — strict-dynamic Trust Not Propagating to Vendor Scripts

Symptom: A nonced bootstrap script loads, but the vendor SDKs it dynamically creates <script> elements for are blocked with Refused to load the script.

Cause: 'strict-dynamic' is absent from the script-src-elem directive, or the bootstrap script itself was not nonced (it was allowed via an origin allowlist, which does not qualify as a trust root for 'strict-dynamic').

Fix: Ensure the bootstrap script carries a valid nonce attribute. Ensure 'strict-dynamic' is listed in script-src-elem. Origin allowlists (https://cdn.vendor.com) combined with 'strict-dynamic' are redundant — 'strict-dynamic' ignores allowlists in supporting browsers, so the nonce is the sole trust signal.


Failure 5 — Post-Insertion Nonce Assignment Silently Fails

Symptom: Dynamically created scripts are blocked even though script.nonce is set in the code.

Cause: The nonce was assigned after document.head.appendChild(script). The browser reads the nonce at insertion time; post-insertion assignment is ignored.

Fix: Always set script.setAttribute('nonce', nonce) before calling appendChild. Use setAttribute rather than the script.nonce property setter — some browsers treat the property assignment as a reflected attribute update but do not re-evaluate the CSP check.


Frequently Asked Questions

Can I use strict-dynamic without nonces?

No. 'strict-dynamic' requires a trust root — a nonce-qualified or hash-qualified script that the browser has already permitted. Without a qualifying token, there is no root from which to propagate trust, and dynamically injected scripts are blocked as if 'strict-dynamic' were absent.

Does CSP affect Largest Contentful Paint?

Header parsing overhead is sub-millisecond. The real LCP risk with CSP comes from blocking vendor scripts that would otherwise initiate preconnects or preloads for hero images. Use <link rel="preload"> for critical images independently, and ensure your CSP connect-src includes analytics endpoints so beacons do not stall.

How do I handle CDN-cached pages that need per-request nonces?

Pages with nonces must carry Cache-Control: private, no-cache. For edge caching scenarios, use an edge worker (Cloudflare Workers with HTMLRewriter) to inject the nonce into a cached HTML shell at edge-request time — the CDN caches the shell without a nonce, and the nonce is appended per request at the edge with zero origin round-trips.

What happens to CSP when a user revokes consent mid-session?

CSP does not unload scripts that have already executed. Consent revocation must be handled at the application layer: stop issuing nonces for new injections, and if the already-loaded vendor script must be deactivated, use its own opt-out API or postMessage teardown. The handling consent revocation without page reload pattern covers the application-layer mechanics.


Related: