Article

Content-Security-Policy-Report-Only: Roll Out CSP Without Breaking Your Site

Content-Security-Policy-Report-Only makes the browser evaluate a CSP and send violation reports without blocking anything. Ship your strict policy in Report-Only, fix what the reports show, then rename the header to enforce it — a Report-Only header on its own protects nothing.

By Paul Rudenko, Security ResearcherUpdated Sep 18, 20267 min read

Content-Security-Policy-Report-Only is a second CSP header that makes the browser evaluate a policy and send violation reports, but block nothing. It is how you roll out a Content-Security-Policy without breaking your site: ship the strict policy in Report-Only, fix what the reports show, then rename the header to enforce it.

What does Report-Only mode actually do?

The browser parses the policy exactly as it would an enforcing one, checks every script, style, image, frame and connection against it — and when something would have been blocked, it lets the resource load and instead sends a JSON violation report to the endpoint you named. Users see no change. You get a precise list of everything the real policy would break. Both headers can be sent at once: an enforcing Content-Security-Policy that's already live, and a stricter -Report-Only one you are testing next (MDN).

One consequence people miss: a site that sends only a Report-Only header has no CSP protection at all. The CSP Checker grades that C — “you're measuring, not protecting” — because it is the right first step but not a finished state.

report-to or report-uri — which directive do I use?

Two directives tell the browser where to send reports, and they come from different generations of the spec:

  • report-uri — CSP Level 1/2. Takes a URL directly. Deprecated in CSP3 but still honoured by every browser, and still the only thing older Safari versions understand.
  • report-to — CSP Level 3. Takes a group name that must be defined in a separate Reporting-Endpoints header. Reports are batched and use the modern Reporting API format (W3C Reporting API).

Send both during the transition; browsers that understand report-to ignore report-uri when both are present. Our checker flags report-uri without report-to as a deprecated-directive note and a policy with neither as “no violation reporting” — because a CSP you can't observe is a CSP you will eventually break.

Reporting-Endpoints: csp-endpoint="https://reports.example.com/csp"
Content-Security-Policy-Report-Only:
  default-src 'self';
  script-src 'self' 'nonce-{RANDOM}' 'strict-dynamic';
  object-src 'none'; base-uri 'self';
  report-to csp-endpoint;
  report-uri https://reports.example.com/csp

Where do the reports go?

Anywhere that accepts an HTTPS POST of JSON. Options, cheapest first:

  • Your own endpoint — a tiny route that appends the body to a log and returns 204. Twenty lines in any framework. Watch the volume: browser extensions and broken ad scripts generate noise, so group by blocked-uri and violated-directive.
  • Hosted collectors — services such as report-uri.com or Sentry's CSP ingest de-duplicate and chart reports for you; most have a free tier that covers a small site.
  • Browser DevTools only — if you have no endpoint yet, violations still print to the console in Report-Only mode, which is enough for a first manual pass on your own pages.
Common gotcha: the report endpoint must be reachable under the policy itself. If your connect-src doesn't allow the reporting host, some browsers drop the report instead of sending it, and you conclude your policy is clean when it is merely silent. Add the reporting origin to connect-src, or use a same-origin path.

For the site owner and for the developer

For the site owner (plain English)

Report-Only is the safe way to add a Content-Security-Policy to a site that never had one. Nothing changes for visitors; you simply collect a list of what a real policy would block, fix those items, and switch it on. If a report says your site “only has a report-only CSP”, the work isn't wrong — it's unfinished. Ask whoever runs the site how long the reports have been quiet; if it's been weeks, the enforcing header can go live.

Paste the policy you're about to ship and tick 'This is a Report-Only policy' — the checker tells you what it will block before your visitors do.

Evaluate a Report-Only policy

For the developer (exact syntax)

# nginx — run the new strict policy in Report-Only next to the existing enforcing one
add_header Reporting-Endpoints 'csp-endpoint="https://example.com/csp-report"' always;
add_header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self' 'nonce-$request_id' 'strict-dynamic'; object-src 'none'; base-uri 'self'; report-to csp-endpoint; report-uri /csp-report" always;
// Next.js — next.config.mjs headers()
{
  source: "/:path*",
  headers: [
    { key: "Reporting-Endpoints", value: 'csp-endpoint="https://example.com/csp-report"' },
    { key: "Content-Security-Policy-Report-Only",
      value: "default-src 'self'; script-src 'self' 'strict-dynamic'; object-src 'none'; base-uri 'self'; report-to csp-endpoint" },
  ],
}
// Minimal collector — Next.js route handler: app/csp-report/route.ts
export async function POST(req: Request) {
  const body = await req.text();            // Reporting API sends application/reports+json
  console.log("csp-violation", body.slice(0, 2000));
  return new Response(null, { status: 204 });
}

A report looks like this — the two fields you triage on are violated-directive (which rule) and blocked-uri (what it would have stopped; inline means an inline script):

{ "csp-report": {
    "document-uri": "https://example.com/pricing",
    "violated-directive": "script-src",
    "blocked-uri": "inline",
    "line-number": 42,
    "script-sample": "window.dataLayer = window.dataLayer || []..." } }

When is it safe to switch to enforcing?

  • Reports have been quiet — or only noise from extensions — for at least a week of real traffic, including the pages behind login.
  • Every third-party widget you actually use has been tested with the policy (payment forms and chat widgets are the usual last holdouts).
  • You keep the Report-Only header as well, pointed at a stricter next version, so the loop continues.
  • Re-run the checker on the live site: the grade should move from C (report-only) to A or B, depending on what's left — see removing 'unsafe-inline' for the usual remaining item.

Frequently asked questions

Does Content-Security-Policy-Report-Only protect my site?

No. Report-Only mode instructs the browser to evaluate the policy and report violations, but to let every resource load as if no policy existed. It is a measurement tool, not a defence. That makes it the right first step when introducing a CSP — you learn exactly what a real policy would block before any visitor is affected — but a site that sends only the Report-Only header has the same exposure to injected scripts as a site with no CSP at all. Once the reports have been quiet through a normal week of traffic, rename the header to Content-Security-Policy so the same rules are enforced, and keep a stricter Report-Only version running alongside it for the next iteration.

What is the difference between report-to and report-uri in CSP?

report-uri is the original directive from CSP Level 1 and takes a URL directly; it is deprecated in CSP Level 3 but still supported by all browsers and remains the only option older Safari releases understand. report-to is the modern replacement: it names a reporting group that you define in a separate Reporting-Endpoints header, and the browser batches reports using the standard Reporting API format. When both directives are present, browsers that understand report-to use it and ignore report-uri, so sending both is safe and gives the widest coverage during a transition. A policy with neither has no way to tell you when it blocks something, which is why our checker flags missing reporting.

Why am I not receiving any CSP violation reports?

The most common cause is that the policy itself blocks the report. Browsers send violation reports with a connection to your reporting endpoint, and if connect-src does not allow that origin, some browsers silently drop the report. Add the reporting host to connect-src or use a same-origin path. Other frequent causes: the Reporting-Endpoints header is missing or malformed when you use report-to, the endpoint returns a non-2xx status or a redirect, the endpoint requires authentication or CSRF tokens the browser will not send, or reports are being posted with the application/csp-report or application/reports+json content type that your framework rejects. Test with the browser console first — violations print there even when delivery fails.

Related guides

See your whole external attack surface

One page is a start. The full external scan covers TLS, headers, DNS, exposed files, open services and known-exploited CVEs across your whole domain.

See the full scan →