How to migrate from CSP Report-Only to Enforce without taking the site down

Use a corpus of existing violation reports to preview what would break before you flip the switch.

GlitchReplay team··
cspsecurityrollout

You've had a Content Security Policy in Report-Only mode for three months. Your logs are overflowing with 50,000 JSON blobs, mostly from browser extensions, old trackers, and "look-alike" malware. Somewhere in that noise is a single report representing your app's core payment processing script. If you flip the switch to Enforce today, do you know which one it is? Most developers don't, so they never flip the switch. They stay in the "Report-Only" purgatory indefinitely, enjoying the illusion of security without actually stopping a single XSS attack.

The "Report-Only" Purgatory: Why migration fails

Moving from Content-Security-Policy-Report-Only to a blocking Content-Security-Policy is the "final boss" of web security. On paper, it sounds simple: see what's breaking in the logs, add those domains to your allowlist, and turn on enforcement. In reality, the volume of violation reports is so noisy and chaotic that it's nearly impossible to trust your data.

The False Security of Report-Only

The Report-Only header is a diagnostic tool, not a security control. It tells the browser: "If this were an enforced policy, I would have blocked this resource. Please send a JSON report to this URL." The browser still executes the script, loads the image, or connects to the websocket. If a malicious actor successfully injects a script into your page, Report-Only will dutifully send you a notification while the attacker drains your users' cookies. It provides the metadata of a breach without the mitigation.

Why "just reading the logs" doesn't work at scale

When you first enable reporting, you expect to see a list of your CDNs and tracking pixels. Instead, you get a firehose of garbage. Modern browsers are battlefields of competing scripts. Users have dozens of extensions installed—password managers, ad blockers, "honey" coupon finders, and dark mode toggles. Many of these work by injecting scripts or CSS directly into the DOM of every page the user visits. Since these injected resources aren't in your CSP, they trigger a violation report. If you have 10,000 daily active users, you might receive 100,000 reports that have absolutely nothing to do with your source code. Distinguishing between a legitimate "missing" script from your marketing team and a "false positive" from a user's "Save to Pinterest" button is a full-time job.

Step 1: Auditing your current violation corpus

To move toward enforcement, you must first clear the brush. You need to filter the 95% of noise that shouldn't influence your policy. If you try to build an allowlist that includes every domain appearing in your Report-Only logs, you will end up with a policy so broad it becomes useless. An allowlist that includes chrome-extension:// or random malware domains is no better than having no policy at all.

Categorizing violations

You should group your reports into three buckets: First-party (your code), Third-party (your vendors), and Noise (the user's environment). Legitimate violations usually happen at high volume across many different IP addresses. If a script from checkout.stripe.com is being blocked, you'll see it across your entire user base. If a script from discount-coupons-daily.biz is being blocked, it's likely restricted to a handful of users with a specific malware extension.

Identifying "Hidden" Dependencies

Filtering isn't just about removing bad data; it's about finding things you forgot. Maybe your analytics script dynamically loads a secondary library from a different domain. Maybe your support chat widget requires eval() or unsafe-inline styles. These are the "landmines" that break your site when you switch to enforcement.

You can use a simple script or a regex in your log aggregator to filter out the most common noise. For example, any source-file or document-uri that starts with a non-web scheme should be discarded immediately:

// Filter common extension and local noise
const isNoise = (report) => {
  const source = report['csp-report']['source-file'] || '';
  const blocked = report['csp-report']['blocked-uri'] || '';
  
  return (
    source.startsWith('chrome-extension://') ||
    source.startsWith('safari-web-extension://') ||
    source.startsWith('moz-extension://') ||
    blocked === 'about:blank' ||
    blocked === 'blob' ||
    blocked.includes('localhost')
  );
};

Step 2: Drafting the "Enforced" Policy

Once you have a clean list of what your app actually needs, you need to draft the policy you *want* to enforce. Don't just copy your Report-Only header. Use this as an opportunity to tighten the screws.

The "Strict-CSP" approach vs. Allowlisting

The traditional way to write a CSP is allowlisting: listing every domain you trust. This is fragile. If a vendor changes their CDN URL, your site breaks. If a trusted domain has an open redirect, your CSP can be bypassed. Google's security team has long advocated for a Strict CSP approach using nonces or hashes. Instead of saying "trust all scripts from apis.google.com," you say "trust any script with this cryptographic nonce that I generated on the server."

Handling Inline Scripts with Nonces

If your application relies on inline <script> tags, you must either move them to external files or add a nonce. A nonce is a random, single-use string generated for every request. Your policy would look like this:

Content-Security-Policy: 
  script-src 'nonce-ed3802425978112' 'strict-dynamic' https: 'unsafe-inline';
  object-src 'none';
  base-uri 'none';

The 'strict-dynamic' keyword tells the browser that if a script with a valid nonce loads another script, that second script is also trusted. This significantly simplifies your policy because you no longer need to list every transitive dependency of your analytics or payment providers.

Step 3: The Gap Analysis (Simulation)

This is where most teams fail. They have their old Report-Only logs and their new "Strict" draft policy. They try to manually "diff" them. "Does this draft block the Stripe script we saw in the logs?" Doing this manually for 5,000 reports is a recipe for downtime. You will miss something.

Why manual diffing is a recipe for downtime

A CSP is a complex state machine. A single change to default-src can have cascading effects on connect-src, font-src, and img-src. You aren't just checking if a domain is present; you are checking if the specific *action* (e.g., a worker-src vs. a script-src) is allowed by the logic of the policy. You need a way to run your historical reports against your proposed header to see what *would* have been blocked.

Simulating the "What If"

Imagine you could take all 50,000 JSON reports from the last 30 days and "re-play" them against your new draft policy. The simulation would tell you: "Out of 50,000 violations we saw in the wild, 49,990 were noise that this new policy correctly ignores, but 10 of them were actually your own site's login widget which this new policy would have broken." This data-driven approach removes the "pray" from "flip the switch and pray."

Step 4: Using the GlitchReplay CSP Rollout Simulator

To make this process painless, we built a free tool called the CSP Rollout Simulator. It acts as a zero-risk dry run for your enforcement strategy.

Importing your JSON violation reports

Most reporting endpoints (like Sentry, or your own custom worker) give you raw JSON export. You can take a representative sample of these reports and paste them directly into the simulator. The tool parses the blocked-uri, the violated-directive, and the effective-directive from every report.

Visualizing the "Block Rate" before it happens

Once your reports are loaded, you paste your proposed Content-Security-Policy header. The simulator runs each report through a CSP evaluation engine. It doesn't just look for string matches; it understands CSP logic, including 'self', wildcards, and directive fallbacks. It will show you a dashboard of "Confirmed Blocks"—the specific resources that would have been blocked by your new policy that were *actually* seen in your Report-Only logs.

For example, you might see an alert that says: "Warning: 142 reports from cdn.intercom.io would be blocked by your current script-src." This is your cue to add Intercom to your policy or implement a nonce before you deploy. You can iterate on the policy text inside the simulator and see the "Block Rate" drop in real-time until it only shows the "Noise" (like chrome-extensions) that you intend to block.

Step 5: The "Shadow" Rollout

Even with a perfect simulation, there is still risk. The "Shadow Rollout" is the final safety measure. This involves deploying two headers simultaneously. Most people don't realize you can send both Content-Security-Policy and Content-Security-Policy-Report-Only in the same response.

Deploying both headers

You set the Content-Security-Policy to a "safe" version (perhaps your old, loose allowlist) to provide some baseline protection. Then, you set Content-Security-Policy-Report-Only to your new, "strict" draft policy. This allows you to gather real-time data on the strict policy while the loose policy is actually doing the enforcing.

// Example Nginx configuration for shadow rollout
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://trusted.cdn.com;";
add_header Content-Security-Policy-Report-Only "default-src 'none'; script-src 'nonce-$request_id' 'strict-dynamic'; report-uri /csp-endpoint;";

Modern Reporting with report-to

If you are targeting modern browsers, you should use the report-to directive instead of the deprecated report-uri. report-to uses the Reporting API, which is more efficient and handles out-of-band reporting better than the old POST-based report-uri. You can find the full spec for this at the W3C CSP Level 3 documentation.

Step 6: Monitoring the "Long Tail"

The day you flip the switch to Enforce, you need to be in the "war room." Even with simulation and shadow rollouts, there will be "long tail" issues—edge cases that only appear for certain browser versions or specific user locales.

Setting up real-time alerts

Don't wait for users to report that the "Buy Now" button is broken. Set up an alert in your monitoring tool (like GlitchReplay or Sentry) that triggers if the volume of CSP violations exceeds a specific threshold. A "normal" level of violations (noise) might be 50 per minute. If that spikes to 5,000 per minute, you know you've broken something fundamental.

The "Break Glass" procedure

Never deploy an enforced CSP without a way to revert it in seconds. If your CSP is hardcoded in your application source code, a revert requires a full CI/CD pipeline run, which might take 10-20 minutes. Instead, manage your CSP headers at the edge—using Cloudflare Workers, Lambda@Edge, or your load balancer config. This allows you to "break glass" and disable the header or revert to Report-Only without touching your application code.

A simple Cloudflare Worker can serve as a toggle:

addEventListener('fetch', event => {
  event.respondWith(handleRequest(event.request))
})

async function handleRequest(request) {
  const response = await fetch(request)
  const newHeaders = new Headers(response.headers)
  
  // Use an environment variable to toggle enforcement
  if (CSP_ENFORCE === 'true') {
    newHeaders.set('Content-Security-Policy', "default-src 'self'...");
  } else {
    newHeaders.set('Content-Security-Policy-Report-Only', "default-src 'self'...");
  }

  return new Response(response.body, {
    status: response.status,
    statusText: response.statusText,
    headers: newHeaders
  })
}

Migrating to an enforced CSP is an exercise in data science, not just security engineering. You are trying to find a signal of legitimate application needs within a massive amount of browser noise. By using a simulator to perform gap analysis against your historical data, you can turn a "guess and check" process into a predictable, engineering-led migration.

Stop guessing if your policy is safe—run your violation logs through the CSP Rollout Simulator to see exactly what will break before you flip the switch. Once you have a clean bill of health in the simulator, you can move to enforcement with the confidence that your site—and your users—are actually protected.

Stop watching your error bill spike.

GlitchReplay is Sentry-SDK compatible, includes session replay and security signals, and never charges per event. Free to start, five minutes to first event.