CSP Violation Reporting and Monitoring
This guide is part of the Security Header Auditing & Compliance reference and covers the reporting pipeline behind a Content-Security-Policy: how browsers emit violation reports, where to receive them, and how to monitor the stream without drowning in noise. A CSP that nobody monitors is a policy you cannot safely tighten — every violation report is a signal that either an attacker probed your page or your own policy is too narrow. This reference treats the report channel as production telemetry: it must be reliable, deduplicated, and queryable, the same way you treat error logging.
The full lifecycle is: ship the policy in Content-Security-Policy-Report-Only to discover what would break, point reports at an endpoint you control, watch the volume and shape of incoming reports, and only flip to enforcement once the report stream is quiet and explainable. For the directive that names the destination, see the report-uri vs report-to migration guide; for turning raw reports into triaged findings, see parsing CSP violation reports.
Threat Model & Protocol Mechanics
CSP reporting exists to close the gap between writing a policy and trusting it. Without a report channel you tighten a policy blind: you either break legitimate functionality in production or you leave the policy so loose that it stops mitigating injection. The report stream is the feedback loop that makes a strict policy operationally survivable.
Report-Only as a discovery tool
Content-Security-Policy-Report-Only instructs the browser to evaluate the policy and emit a violation report for every resource it would have blocked, without actually blocking anything. This is the only safe way to author a strict policy against a real site. Ship the candidate policy in Report-Only, collect a week of reports across your real traffic and browser mix, fold the legitimate sources into the policy, and repeat until the report stream contains only noise or genuine attacks. Only then move the same policy to the enforcing Content-Security-Policy header.
A page may carry both headers simultaneously with different policies: a strict enforced policy plus an even stricter Report-Only policy you are testing for the next tightening pass. Each header reports independently, and the disposition field (enforce vs report) tells your collector which one fired.
report-uri vs report-to and the Reporting-Endpoints header
There are two delivery mechanisms, and a robust collector handles both:
report-uri <url>— the legacy CSP directive. The browser sends onePOSTper violation, immediately, withContent-Type: application/csp-report, body wrapped in a{"csp-report": {…}}envelope using hyphenated field names (document-uri,violated-directive,blocked-uri).report-to <group-name>— the modern directive. It does not take a URL; it names an endpoint group that the browser registers from a separate response header. The browser batches, queues, and retries delivery, postingContent-Type: application/reports+jsonas a JSON array of envelopes using camelCase field names (documentURL,effectiveDirective,blockedURL).
The group is registered with the Reporting-Endpoints header (Reporting API v1), which uses simple structured-field syntax:
Reporting-Endpoints: csp-endpoint="https://reports.example.com/csp"
Content-Security-Policy: default-src 'self'; report-uri https://reports.example.com/csp; report-to csp-endpoint
The older Report-To header (a JSON object with group/max_age/endpoints) predates Reporting-Endpoints and is being phased out; use Reporting-Endpoints for new work. Browsers that support the Reporting API prefer report-to and ignore report-uri; older browsers fall back to report-uri. Keeping both directives during a transition yields no reporting gaps — the full mechanics live in the report-uri vs report-to migration guide.
Browser report payload shape
A report-uri (legacy) delivery looks like this:
{
"csp-report": {
"document-uri": "https://example.com/account",
"referrer": "",
"violated-directive": "script-src-elem",
"effective-directive": "script-src-elem",
"original-policy": "default-src 'self'; report-uri https://reports.example.com/csp",
"blocked-uri": "https://evil.example/inject.js",
"disposition": "report",
"status-code": 200,
"line-number": 17,
"source-file": "https://example.com/account"
}
}
A report-to (Reporting API) delivery wraps the same data differently — an array, camelCase fields, and a type/age/url outer envelope:
[
{
"type": "csp-violation",
"age": 12,
"url": "https://example.com/account",
"body": {
"documentURL": "https://example.com/account",
"effectiveDirective": "script-src-elem",
"blockedURL": "https://evil.example/inject.js",
"disposition": "report",
"statusCode": 200,
"lineNumber": 17,
"sourceFile": "https://example.com/account"
}
}
]
Your receiver must normalize both shapes into one internal record. The per-field semantics — what blocked-uri, violated-directive, document-uri, and disposition actually mean and how to triage them — are covered in parsing CSP violation reports.
Noise from extensions and injected scripts
The single biggest operational surprise is that most reports on a public site are not attacks and not your bug — they are browser extensions, antivirus content scanners, ISP-injected scripts, and corporate proxies modifying the page. These show up as violations with blocked-uri values like inline, eval, data:, about, chrome-extension:, moz-extension:, safari-extension:, or opaque values where the browser reports only the scheme. They are indistinguishable from a real attack at the network layer, so you filter them at triage time. Plan for a 10:1 or worse noise-to-signal ratio on consumer traffic.
Directive Syntax & Spec
The reporting directives attach to any Content-Security-Policy or Content-Security-Policy-Report-Only header. They do not block anything themselves; they only redirect the violation telemetry.
Reporting-Endpoints: csp-endpoint="https://reports.example.com/csp"
Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self' 'nonce-r4nd0m'; report-uri https://reports.example.com/csp; report-to csp-endpoint
| Directive / Header | Type | Default | Security Impact | When to Deviate |
|---|---|---|---|---|
Content-Security-Policy-Report-Only |
Response header | none (no policy) | Detects violations without blocking — zero enforcement | Always use first to author a strict policy; never as the only header on a finished policy |
report-uri <url> |
CSP directive | none | Legacy delivery; immediate per-violation POST | Keep for older-browser coverage until legacy traffic is negligible |
report-to <group> |
CSP directive | none | Modern delivery; batched, retried, queued | Preferred for new deployments; needs a matching Reporting-Endpoints group |
Reporting-Endpoints |
Response header | none | Registers the named group report-to resolves |
Required whenever report-to is used |
disposition (report field) |
Report field | enforce |
Tells the collector whether the policy was enforcing or report-only | Read-only; route report and enforce to separate dashboards |
Malformed-syntax gotchas: report-to takes a group name, not a URL — putting a URL there silently disables modern reporting. The Reporting-Endpoints group name must match the report-to token byte-for-byte. A Content-Security-Policy-Report-Only header with no report-uri/report-to directive does nothing useful: the browser shows a console warning but sends no report, so you learn nothing. Reporting endpoints must be absolute HTTPS URLs; relative URLs and http: collectors are ignored by modern browsers.
Platform-Specific Implementation
Each receiver below must (1) accept both application/csp-report and application/reports+json, (2) return 204 quickly so the browser does not retry, and (3) never let report traffic block the request path of real users.
Node/Express endpoint to receive reports
const express = require('express');
const app = express();
// Accept BOTH legacy and Reporting API content types.
app.post(
'/csp',
express.json({ type: ['application/csp-report', 'application/reports+json'] }),
(req, res) => {
// Respond immediately so the browser does not retry; process async.
res.status(204).end();
const body = req.body;
const reports = Array.isArray(body)
? body.map((r) => r.body) // report-to: array of envelopes
: [body['csp-report'] || body]; // report-uri: single csp-report envelope
for (const r of reports) {
if (!r) continue;
const blocked = r.blockedURL || r['blocked-uri'] || '';
// Drop obvious extension/proxy noise before it hits storage.
if (/^(chrome|moz|safari)-extension:|^about$|^null$/.test(blocked)) continue;
queue.push(r); // hand off to async aggregation
}
}
);
res.status(204).end() before processing keeps report handling off the user-facing latency budget. Filtering *-extension: at ingest cuts storage cost on noisy public sites.
Django/FastAPI receiver
# Django (urls.py + view)
import json, re
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
NOISE = re.compile(r"^(chrome|moz|safari)-extension:|^about$|^null$")
@csrf_exempt # browsers POST reports without a CSRF token
def csp_report(request):
if request.method != "POST":
return HttpResponse(status=405)
try:
payload = json.loads(request.body or b"{}")
except json.JSONDecodeError:
return HttpResponse(status=400)
records = payload if isinstance(payload, list) else [payload.get("csp-report", payload)]
for rec in records:
body = rec.get("body", rec) # report-to nests under "body"
blocked = body.get("blockedURL") or body.get("blocked-uri") or ""
if NOISE.match(blocked):
continue
store_violation(body) # async task / queue
return HttpResponse(status=204)
# FastAPI equivalent
from fastapi import FastAPI, Request, Response
app = FastAPI()
@app.post("/csp", status_code=204)
async def csp_report(request: Request):
payload = await request.json()
records = payload if isinstance(payload, list) else [payload.get("csp-report", payload)]
for rec in records:
body = rec.get("body", rec)
# enqueue body for async aggregation
return Response(status_code=204)
@csrf_exempt is mandatory in Django: the browser sends violation reports as cross-context POSTs with no CSRF token, so the default middleware would reject every report with 403. The Django integration ties into the FastAPI / Django security middleware reference for where to emit the policy itself.
Using a hosted collector like report-uri.com
If you do not want to run and scale a receiver, point the directives at a hosted service. The directive change is the entire integration:
Reporting-Endpoints: csp-endpoint="https://yoursubdomain.report-uri.com/r/d/csp/reportOnly"
Content-Security-Policy-Report-Only: default-src 'self'; report-uri https://yoursubdomain.report-uri.com/r/d/csp/reportOnly; report-to csp-endpoint
A hosted collector absorbs report flooding, deduplicates, and groups by blocked-uri/document-uri for you, which is the fastest path to a usable dashboard. The trade-off: violation reports contain URL paths and occasionally query strings, so you are sending request metadata to a third party — review their data-retention terms before pointing production traffic at them.
Cloudflare
Set the reporting headers at the edge with a Worker so the policy and endpoint ship even when the origin omits them:
export default {
async fetch(request) {
const response = await fetch(request);
const headers = new Headers(response.headers);
headers.set('Reporting-Endpoints', 'csp-endpoint="https://reports.example.com/csp"');
headers.set(
'Content-Security-Policy-Report-Only',
"default-src 'self'; report-uri https://reports.example.com/csp; report-to csp-endpoint"
);
return new Response(response.body, { status: response.status, headers });
},
};
Setting the Report-Only header at the edge lets you A/B a stricter candidate policy on a fraction of traffic without an origin deploy. The receiving /csp route can itself be a separate Worker writing to a queue or D1. See the Cloudflare page rules and headers reference for header-injection placement.
Verification & Diagnostic Workflows
Confirm the headers ship, that the collector accepts a report, then trigger a real violation.
# 1. Headers present and well-formed
curl -sI https://example.com | grep -iE 'reporting-endpoints|content-security-policy'
Expected output:
reporting-endpoints: csp-endpoint="https://reports.example.com/csp"
content-security-policy-report-only: default-src 'self'; report-uri https://reports.example.com/csp; report-to csp-endpoint
# 2. Collector accepts a legacy csp-report POST
curl -s -o /dev/null -w '%{http_code}\n' -X POST \
-H 'Content-Type: application/csp-report' \
-d '{"csp-report":{"document-uri":"https://example.com/","violated-directive":"script-src","blocked-uri":"https://evil.example/x.js","disposition":"report"}}' \
https://reports.example.com/csp
Expected output: 204. A 403 from a Django endpoint means @csrf_exempt is missing; 415 means the route rejects the Content-Type; a CORS/preflight error means the endpoint lacks Access-Control-Allow-Origin for cross-origin collectors.
Browser DevTools path: load a page that violates the policy (an inline script with no nonce is the simplest trigger), open DevTools → Console to see the Refused to execute inline script… warning, then DevTools → Network, filter by your collector host, and confirm a POST whose body contains blocked-uri/blockedURL and disposition: "report". Reporting API (report-to) delivery is batched, so allow up to a minute; report-uri fires immediately.
CI/CD integration: assert the Report-Only header exists in a pipeline smoke test so a deploy can never silently drop the report channel.
# Fail the build if the report endpoint disappears from the policy
curl -sI https://staging.example.com \
| grep -iq 'content-security-policy-report-only:.*report-to csp-endpoint' \
|| { echo 'CSP report-to endpoint missing'; exit 1; }
This slots into the broader automated header scanning in CI/CD workflow.
Troubleshooting, Misconfigurations & Safe Rollback
- No reports arriving at all → confirm the directive is
report-uri <url>(absolute HTTPS) and thatreport-tonames a group present inReporting-Endpoints. Areport-topointed at a URL instead of a group name silently sends nothing. - Reports arrive but the collector returns 403 → Django/Rails CSRF middleware is rejecting the tokenless cross-context POST. Exempt the route (
@csrf_exempt). - Report flooding / cost blowout → a single malicious or buggy page can emit thousands of reports per second. Rate-limit per source IP at the edge, sample (accept 1 in N) when volume spikes, and dedupe by hash of
document-uri+violated-directive+blocked-uriwithin a time window before storing. - CORS error in console for the report POST → modern Reporting API deliveries are subject to CORS preflight when the collector is cross-origin. Return
Access-Control-Allow-Origin,Access-Control-Allow-Methods: POST, and allow theContent-Typeheader onOPTIONS. - Overwhelmed by
blocked-uri: inline/eval→ these are real policy gaps, not noise: adopt nonces or hashes instead of wideningscript-src. See generating CSP nonces per request. report-urideprecation warnings →report-uriis deprecated but still the only mechanism some browsers honor; keep it alongsidereport-to, do not remove it on the warning alone.- Duplicate reports → proxy retries and mixed fleets cause duplicates even when both directives are configured correctly; deduplicate server-side, never by trusting the browser to send once.
Safe rollback (non-destructive): Report-Only headers never block anything, so removing them only stops telemetry — it cannot break a page. To stop a report flood immediately, drop the report-uri/report-to directives (or the whole Report-Only header) and reload; the enforced policy is untouched.
# Stop reporting without touching enforcement: remove the report directives, then
# nginx -t && systemctl reload nginx
Frequently Asked Questions
Should I report from the enforcing policy or only Report-Only?
Both. Report-Only is for authoring and tightening the next policy; the enforced policy should also carry report-uri/report-to so you see real-world blocks and attack probes in production. Route them to separate views using the disposition field (report vs enforce).
Why is 90% of my report volume from browser extensions?
Extensions, antivirus scanners, and ISP/corporate proxies inject scripts and styles into the page, which your policy correctly flags. They surface as blocked-uri values like chrome-extension:, inline, eval, or data:. Filter them at triage rather than widening the policy — see parsing CSP violation reports.
How do I stop a report flood from running up costs?
Return 204 fast, rate-limit per source IP at the edge, sample under load, and deduplicate by hashing document-uri + violated-directive + blocked-uri in a short window. A single buggy page can otherwise emit thousands of reports per second.
Do I have to run my own collector? No. A hosted collector such as report-uri.com is a directive-only integration and absorbs flooding and deduplication for you. The trade-off is sending request URL metadata to a third party, so review retention terms first.
What’s the difference between the two report payload shapes?
report-uri posts a single {"csp-report": {…}} object with hyphenated fields (blocked-uri); report-to posts a JSON array of envelopes with camelCase fields (blockedURL) nested under body. A robust receiver normalizes both into one record.
Related
- Security Header Auditing & Compliance — the parent reference for auditing and monitoring header deployments.
- Parsing CSP violation reports — field-by-field triage of the report payloads.
- Migrating CSP from report-uri to report-to — the directive migration that backs this pipeline.
- Content-Security-Policy (CSP) reference — the policy whose violations you are collecting.
- Automated header scanning in CI/CD — assert the report channel survives every deploy.