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 separateReporting-Endpointsheader. 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/cspWhere 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-uriandviolated-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.
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.