Skip to main content
Shipping Content Security Policy safely with report-only mode
headers9 min readBy Website Auditor ResearchUpdated 2026-01-10

Shipping Content Security Policy Without Breaking Production

CSP is one of the most effective XSS defenses ever shipped — and one of the most feared to deploy. Report-only mode turns a scary launch into a boring one.

What CSP defends against

Content Security Policy is a header that tells the browser exactly which sources of script, style, image, font and connection are allowed to load on your page. Anything not on the list is blocked. Its headline job is stopping cross-site scripting (XSS): even if an attacker manages to inject a <script> tag or an inline event handler, a strict CSP means the browser refuses to execute it. XSS remains one of the most common and damaging web vulnerabilities because a single injected script runs with the full privileges of the logged-in user — it can steal session tokens, rewrite the page, or exfiltrate data. CSP is defense in depth: you still sanitize input and encode output, but CSP is the safety net that catches what slips through. A modern policy also blocks mixed content, restricts where forms can submit, and can forbid your page from being framed, folding several older protections into one place.

Why report-only first

The reason teams fear CSP is that a policy that is even slightly too strict breaks the site — a blocked analytics script, a missing web font, a broken embedded video. Deploy it wrong in enforcement mode and you find out from angry users. Report-only mode removes that risk entirely. The Content-Security-Policy-Report-Only header makes the browser evaluate your policy exactly as it would in enforcement mode — same rules, same matching — but instead of blocking anything, it sends a JSON violation report to an endpoint you choose. Users see nothing. Nothing breaks. That means you can ship a genuinely strict policy on day one, watch what it would have blocked on real production traffic across real browsers and devices, and tune it against reality instead of guessing. When the reports go quiet, you know enforcement is safe. It turns a high-stakes launch into a data-driven, boring one — which is exactly what you want from a security control.

Set up a reporting endpoint

You need somewhere for violation reports to land. Use the report-to directive (the modern replacement for the deprecated report-uri) together with a Reporting-Endpoints header that names a URL. The browser batches violations and POSTs them there as JSON. The endpoint itself can be tiny: a serverless function or a small Cloudflare Worker that accepts the POST, validates it, and forwards it to your log aggregator or a database table. Each report tells you the blocked URL, the directive that would have blocked it (script-src, style-src and so on), and the page it happened on. Expect volume, and expect noise. Browser extensions, injected corporate proxies and antivirus tools all generate violations that are not your code. Plan to group reports by the violated directive and blocked host so the real patterns surface above the noise. A little rate-limiting on the endpoint keeps a misbehaving client from flooding your logs.

Reading the reports

Once reports flow in, you will see everything your pages actually load: Google Fonts, your analytics vendor, a chat widget, a heatmap tool, an A/B testing snippet, and — almost always — one or two inline scripts your CMS or a plugin still injects. This inventory is itself valuable; most teams discover third-party dependencies they had forgotten about. Sort the violations into three buckets. First, legitimate first-party and vendor resources you intend to keep — these go on the allowlist. Second, things that should not be there at all, like an old tracker or an abandoned widget — remove them at the source. Third, noise from extensions and proxies — ignore it, but learn to recognize it so it doesn't distract you. Give it real time. For a typical B2B app, two weeks captures nearly every legitimate variation. For marketing-heavy sites with rotating campaigns and embeds, give it a month so seasonal scripts show up.

Building the allowlist

Now translate what you learned into a policy. Start from a deny-by-default base — default-src 'self' — and open only what the reports proved you need. Add specific hosts to script-src, style-src, img-src, font-src and connect-src rather than reaching for wildcards. Avoid 'unsafe-inline' for scripts; it defeats most of CSP's XSS protection. If you have unavoidable inline scripts, prefer a nonce or a hash: generate a per-response nonce, attach it to your legitimate <script> tags, and add 'nonce-…' to script-src. For third parties that load further scripts, 'strict-dynamic' with a nonce is often cleaner than maintaining a long host list. Keep object-src 'none' and base-uri 'self' — cheap directives that close real attack vectors. Add frame-ancestors to control who can frame you, replacing the older X-Frame-Options. Every time you tighten the policy, watch the report stream for a day to confirm you didn't cut something real.

Flipping to enforcement

When the reports have been quiet — no legitimate resource blocked for a solid week across your normal traffic — you are ready. Rename the header from Content-Security-Policy-Report-Only to Content-Security-Policy. That single change turns the same, already-validated policy into an enforced one. Because you tested against real traffic, the switch is uneventful. Do not remove reporting when you enforce. Keep the report-to directive on the enforcing header so you are notified the moment a future code change or new vendor violates the policy. CSP is not set-and-forget: every new marketing tag or feature can introduce a resource the policy blocks, and the report stream is your early-warning system. Finally, verify with Website Auditor. It will confirm the enforcing header is present, flag 'unsafe-inline' or overly broad wildcards that weaken the policy, and check that companion directives like object-src and frame-ancestors are set. Treat a passing CSP as a living configuration you re-check on every significant release. The pattern that makes CSP painless is worth repeating, because it applies to almost any risky header: deploy in report-only mode, measure against real traffic, allowlist what is genuinely used, then enforce the exact policy you already validated. Teams that skip the measurement step and enforce a hand-written policy are the ones who end up rolling it back at 2am. Teams that let the reports lead ship a strict, XSS-resistant policy on the first try — and keep it healthy because the report stream tells them the moment a new dependency steps out of line.

Frequently asked questions

Long enough to observe every legitimate variation of traffic. For most B2B apps that is about two weeks; for sites with heavy, rotating marketing pages, give it a month so campaign scripts and seasonal embeds show up before you enforce.
Yes. Browsers support both headers simultaneously. A common pattern is to enforce a known-good policy while testing a stricter candidate policy in report-only mode, then promote the candidate once its reports are clean.
'unsafe-inline' allows any inline script or style to run, which is exactly what an XSS attacker injects — so it removes most of CSP's protection. Use per-response nonces or hashes for the inline scripts you genuinely need instead.
No. CSP is a safety net, not a substitute. You still validate input and encode output; CSP catches the injection that slips past those defenses and stops it from executing.
Read next

Related guides