How to move from CSP report-only to enforced without breaking production
A rollout runbook for switching from Content-Security-Policy-Report-Only to an enforced header: clean the report stream, wait for a real quiet period, enforce path by path with both headers running, and keep a rollback that is a header change, not a deploy.
Every CSP migration has the same scary moment: renaming Content-Security-Policy-Report-Only to Content-Security-Policy. One word changes and the browser goes from filing reports to blocking scripts, styles and frames on live traffic. We have watched teams sit in report-only for a year because nobody wanted to own that rename.
The safe version is boring and gradual: clean your violation stream first, wait until it stays flat through a period that represents real traffic, then enforce path by path rather than site-wide, keeping both headers running the whole time (the enforced policy plus a stricter report-only candidate). Your rollback is a header change, not a deploy. That is the whole strategy. The rest is the runbook.
Step 1: clean the report stream
An enforced policy blocks exactly what report-only was reporting, so every violation in your stream is either a future outage or noise you need to classify before it hides one.
Legitimate violations get fixed at the source: the inline handler someone added in 2019, the analytics tag marketing deployed without telling anyone, the font loaded from a CDN nobody remembers choosing. Each one is a small ticket. Do them now, while they are warnings.
Then there is the noise. Browser extensions inject scripts into your users’ pages, and those injections show up in your reports as violations of your policy. They are not your bug and they will never flatline, because you do not control your users’ browsers. Learn to recognize them (odd chrome-extension:// or moz-extension:// origins, script URLs that exist on no server you own) and exclude them from your definition of “quiet”. This triage is where a reporting platform earns its keep. CentralCSP indexes the stream by directive, origin and document URL so you can separate “our checkout is broken” from “someone runs a coupon extension”.
Step 2: tighten until the stream flatlines, for long enough
Do not wait for quiet with a loose policy. Tighten the report-only policy iteratively until it is the policy you actually want to enforce, then wait for the flatline.
How long? Long enough to cover your real traffic patterns. That means at least one full deploy cycle (deploys are where new inline scripts sneak in), one marketing campaign if you run them (campaign landing pages love third-party pixels), and any periodic traffic your product has, like month-end billing pages. We wrote more on picking the observation window in how long to run report-only. The short answer is two to four representative weeks, not a calendar quarter. CentralCSP lets you check violation counts over any 1 to 90 day window, which makes “has this directive been quiet since the spring campaign” a filter rather than a spreadsheet.
Step 3: enforce path by path, not site-wide
Your CDN, reverse proxy or framework middleware can set different headers on different routes, so use that: enforce on one path first, keep report-only everywhere else.
Start where risk is low and value is high. A static marketing page with no third-party scripts is a fine first candidate. Then authenticated app pages, which tend to have fewer third-party tags than public ones. Checkout and payment pages go last, not because the policy there matters least (it matters most) but because you want your enforcement process debugged before it touches revenue. App-by-app works the same way if you run several applications behind one domain. Each path you enforce is a rehearsal for the next one, with a blast radius you chose.
Step 4: run both headers, the dual-header pattern
Enforcement day is not the day you stop observing. HTTP allows both CSP headers on the same response, evaluated independently:
Content-Security-Policy: script-src 'self' https://js.stripe.com; report-to csp-endpoint
Content-Security-Policy-Report-Only: script-src 'nonce-{random}' 'strict-dynamic'; report-to csp-endpoint
The enforced header is your validated policy. The report-only header is the stricter candidate you want to get to next: fewer allowed hosts, nonces instead of a host list. The candidate accumulates evidence against production traffic while the enforced policy protects it. When the candidate flatlines, it gets promoted, and you draft a new one.
Building that candidate from your existing violation data is what the CentralCSP Builder does: it assembles a policy from 1 to 90 days of real reports, value by value, with the violations that justify each entry. (One warning: upgrade-insecure-requests does nothing in a report-only header, so keep it in the enforced one. Details in our article on that console warning.)
Step 5: read the disposition field
With two headers reporting to one endpoint, you need to know which fired. Violation reports carry a disposition field: enforce for the blocking header, report for the report-only one.
After the switch, that field is your dashboard. enforce reports mean users are being blocked right now: they are incidents. report reports are the candidate policy doing its job, and they are backlog. Tooling that cannot split the stream on disposition ends up treating both with the same urgency, which in practice means ignoring both.
Step 6: rollback is a header change
Before enforcing anything, answer one question: how fast can you turn the header back into report-only, and who can do it at 2 am? The answer should be “minutes, via the CDN or proxy config” and not “whenever the next deploy pipeline finishes”. If the header is baked into application code with a 40-minute build, move it to the edge first. This is the cheapest insurance in the whole migration.
What actually breaks at enforcement
Four causes cover most incidents we see:
Cached HTML is the sneakiest. Pages cached by your CDN before the switch carry old nonces, or reference a script your final policy dropped. Report-only was quiet because the policy and the HTML were deployed together. The cache breaks that pairing. Purge or lower TTLs on HTML before flipping.
Browser extensions keep injecting scripts after enforcement exactly as before. Users blame your site. Nothing on your side is broken.
Path-specific scripts are the report-only blind spot: a payment widget or analytics tag that loads only on routes your observation traffic barely touched. Path-by-path enforcement (step 3) exists largely because of these.
A/B testing tools inject different scripts per variant, sometimes inline. A variant launched after your quiet period ends is new code your policy has never seen. This is also the argument for keeping alerting on after enforcement: from the Business plan, CentralCSP can alert on any new script origin appearing in your stream, evaluated as the reports arrive rather than on a schedule, which turns “the Thursday experiment broke checkout” into a Slack or Teams message instead of a support ticket.
Enforce one path this week. The rename gets less scary every time you do it.
Frequently asked questions
When is it safe to enforce a CSP?
When the report-only stream has been flat for a period that covers your real traffic patterns: at least one full deploy cycle, one marketing campaign if you run them, and ideally a month-end if your product has one. A quiet Tuesday proves nothing. Two to four representative weeks with zero legitimate violations is the usual bar.
Can I run Content-Security-Policy and Content-Security-Policy-Report-Only at the same time?
Yes, and during a migration you should. The two headers are evaluated independently: the enforced one blocks, the report-only one observes. The standard pattern is to enforce your current validated policy while a stricter candidate runs in report-only, so the next tightening step is always being tested against production traffic.
CSP enforcement broke my site. What do I roll back?
The header, not the code. Rename Content-Security-Policy back to Content-Security-Policy-Report-Only at whatever layer sets it (CDN, reverse proxy, edge config) and the site works again within seconds, while reports keep flowing so you can find what you missed. If your only way to change a response header is a full application deploy, fix that before you enforce.
Why did violations appear only after I enforced, when report-only was quiet?
Usually cached HTML. Pages cached before the switch can carry old nonces or reference scripts your new policy no longer allows, and they only start failing once the policy blocks instead of observes. Browser extensions are the other classic: they inject scripts into your users' pages, they were reporting all along, and post-enforcement they simply keep reporting, now with an enforce disposition.