How to roll out a stricter CSP without breaking production
Don't replace your Content Security Policy in one jump. Move through four stages — Observe, Constrain, Harden, and Lock down — each shipped as Content-Security-Policy-Report-Only first, with a concrete "Safe to advance" condition before you flip the switch. The Migration plan tab in the CSP Evaluator generates the exact header for every stage from your current policy.
Why a staged rollout
- A CSP that works locally can break in production the moment a different CDN, subpath, or framework build is involved. The staged rollout gives you a window to see what would actually be blocked before it is.
- Each stage is reversible: ship the next header as Report-Only, watch the report stream, and only flip to Enforce when you have evidence the policy is safe.
- Every stage targets a specific failure mode, so the violations you see in the report stream are easy to map to the change that produced them.
Stage 1 — Observe (≥ 7 days)
Add 'report-sample' and 'strict-dynamic' to your existing script-src and ship the entire policy as Content-Security-Policy-Report-Only. The browser will not enforce anything yet, but every script the browser would have blocked under a strict, nonced script-src shows up in the report stream with a sample of its first ~40 characters.
- Why this stage: lets you learn the real script origin universe — what your pages actually load — without changing runtime behaviour.
- Safe to advance when: the report stream is flat or trending down, every
blocked-URImaps to a script you can identify, and you have nonces wired into the inline script tags of your main pages. - Send reports to: a
report-uriendpoint that logs the body, or use the report-only stream from your CDN to inspect the same data.
Stage 2 — Constrain (≥ 7 days)
Drop the dangerous tokens from script-src — *, https:, data:, 'unsafe-inline', 'unsafe-eval', 'unsafe-hashes' — and add a 'nonce-{NONCE}' token. The browser now only trusts scripts that carry a matching nonce, or that were loaded by a nonced script under 'strict-dynamic'. XSS payloads that try to inject new scripts fail at load time.
- Why this stage: converts the report stream from a learning exercise into an enforcement test. Anything you missed in Observe will now show up as a real violation in the report.
- Safe to advance when: no new
script-srcviolations in the report stream for 7 days, every legitimate inline script and its dependencies are accounted for, and you have stopped adding new inline scripts. - Watch for: third-party tags that dynamically inject scripts without a nonce, and analytics snippets that fall back to
document.write.
Stage 3 — Harden (≥ 7 days)
Fill in the safety directives every production CSP should have — base-uri 'self', object-src 'none', frame-ancestors 'none', form-action 'self' — tighten img-src so it is no longer * or https:, and switch the header from Report-Only to Enforce. This is the first time anything is actually blocked.
- Why this stage: closes the directive gaps that allowed clickjacking,
<base>hijacks, and untrusted image hosts to slip past the script allowlist. - Safe to advance when: no user-facing regression reports in 7 days, and any remaining CSP violations come from a known, documented source with a tracking ticket.
- Watch for: forms that POST to an off-site endpoint (form-action), legacy flash embeds (object-src), and iframes loaded by partners (frame-ancestors).
Stage 4 — Lock down (ongoing)
Add require-trusted-types-for 'script' and a trusted-types default policy so any future inline string passed to a sink has to go through an allowlisted factory. Trusted Types turns the remaining injection vectors (DOM-based XSS, dangerous sinks) into a build-time problem rather than a runtime one.
- Why this stage: final tighten before you turn off the report-only channel entirely. It is also the stage that requires code changes — every direct call to
element.innerHTML,document.write, orevalhas to go through a Trusted Types policy. - Safe to advance when: all your sinks are wrapped in Trusted Types, the report-only channel can be retired, and the policy is the version that lives in version control.
- Watch for: third-party libraries that bypass Trusted Types, and old code paths that use
setAttribute('on…', …)instead ofaddEventListener.
Pre-flight checklist
- Confirm your report endpoint is reachable from production and logs the raw body, not just the count.
- Wire nonces into your templating layer so every inline
<script>and<style>tag emits a fresh value per request. - Decide which header file owns the policy on your platform:
_headers(Cloudflare Pages / Netlify),vercel.json, or an edge middleware file. - Have a rollback plan. Each stage's header value is the only thing that needs to change to revert.
- Keep the report-only header alongside the enforcing one for at least one full traffic cycle after each flip, so you can compare streams.
Generate the exact headers
Open the CSP Evaluator — Migration plan tab, paste your current policy, and the wizard produces the four stage headers with copy buttons. Each card includes a "Why this stage" explanation and a "Safe to advance" condition so the rollout has a paper trail.
Next step
Open the generator and keep report-only on until the policy survives a full traffic cycle with no surprises.