Collecting CSP Violation Reports with the Reporting API
Permalink to "Collecting CSP Violation Reports with the Reporting API"Part of Security Reporting & Violation Telemetry, this page is the copy-pasteable reference for the collector itself — the exact Reporting-Endpoints and report-to headers, the application/reports+json body shape, and a working Node/Express and Cloudflare Worker endpoint that parses it.
Quick Reference
Permalink to "Quick Reference"| Property | Modern (Reporting API) | Legacy (report-uri) |
|---|---|---|
| Endpoint declaration | Reporting-Endpoints: csp="https://…" response header |
none — URL inline in the directive |
| CSP directive | report-to csp |
report-uri https://… |
| Request content-type | application/reports+json |
application/csp-report |
| Body shape | JSON array of report objects | single { "csp-report": {…} } object |
| Violation field | report.body.effectiveDirective |
csp-report["effective-directive"] |
| Blocked resource | report.body.blockedURL |
csp-report["blocked-uri"] |
| Delivery | batched, delayed | immediate, per-violation |
| Browser support | Chromium 96+, Firefox 127+ | all engines incl. Safari |
Declare the endpoint once, reference it from the policy, and keep report-uri alongside report-to so no browser is left un-instrumented.
How Report Delivery Works
Permalink to "How Report Delivery Works"The Reporting API separates declaring where reports go from routing a policy to them. The Reporting-Endpoints response header maps a name to a URL; the CSP report-to directive references that name. When a policy is violated the browser builds a report object, queues it, and later POSTs a batch of queued reports as a JSON array with content-type application/reports+json. Because delivery is batched and best-effort, a collector must be idempotent-friendly, fast to respond, and tolerant of both the modern array body and the legacy single-object body from browsers that only speak report-uri.
Canonical Example
Permalink to "Canonical Example"Serve these two headers on the responses that carry your policy. The endpoint name csp is arbitrary but must match between the two headers.
Reporting-Endpoints: csp="https://reports.example.com/csp"
Content-Security-Policy:
default-src 'self';
script-src 'self' https://cdn.example.com;
require-sri-for script style;
report-uri https://reports.example.com/csp-legacy;
report-to csp
A minimal Node/Express collector that parses the application/reports+json body, filters to CSP violations, and persists each record:
// collector.js — run with: node collector.js
import express from 'express';
const app = express();
// The modern Reporting API uses application/reports+json (an array).
// The legacy report-uri directive uses application/csp-report (one object).
const reportParser = express.json({
type: ['application/reports+json', 'application/csp-report', 'application/json'],
limit: '64kb',
});
app.post('/csp', reportParser, (req, res) => {
// Normalise both shapes into a flat list of violation bodies.
const items = Array.isArray(req.body) ? req.body : [req.body];
for (const item of items) {
// Modern: { type: 'csp-violation', body: {…}, user_agent }
// Legacy: { 'csp-report': {…} }
const legacy = item['csp-report'];
const body = legacy ?? (item.type === 'csp-violation' ? item.body : null);
if (!body) continue;
persist({
directive: body.effectiveDirective ?? body['effective-directive'],
blockedURL: body.blockedURL ?? body['blocked-uri'],
documentURL: body.documentURL ?? body['document-uri'],
disposition: body.disposition ?? 'enforce',
sample: body.sample ?? null,
userAgent: item.user_agent ?? req.get('user-agent'),
at: new Date().toISOString(),
});
}
res.sendStatus(204); // fast, empty, cacheable-free acknowledgement
});
function persist(record) {
// Replace with a real datastore write; treat every field as untrusted.
console.log(JSON.stringify(record));
}
app.listen(8080, () => console.log('CSP collector on :8080'));
The shape of a single modern report the browser POSTs looks like this — a csp-violation inside an array:
[
{
"type": "csp-violation",
"age": 210,
"url": "https://www.example.com/checkout",
"user_agent": "Mozilla/5.0 …",
"body": {
"documentURL": "https://www.example.com/checkout",
"effectiveDirective": "script-src",
"disposition": "enforce",
"blockedURL": "https://cdn.evil.example/skimmer.js",
"statusCode": 200,
"sample": ""
}
}
]
Variant Examples
Permalink to "Variant Examples"Cloudflare Worker collector
Permalink to "Cloudflare Worker collector"A Worker is a good fit because it terminates at the edge, needs no server, and can front a queue or KV store. It also has to answer the CORS preflight when the collector is on a different origin from the site.
// worker.js — Cloudflare Worker CSP collector
export default {
async fetch(request, env) {
if (request.method === 'OPTIONS') {
// Reporting API sends a preflight for the cross-origin POST.
return new Response(null, {
headers: {
'Access-Control-Allow-Origin': 'https://www.example.com',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
},
});
}
if (request.method !== 'POST') return new Response('', { status: 405 });
const items = await request.json().catch(() => []);
for (const item of Array.isArray(items) ? items : [items]) {
const body = item['csp-report'] ?? (item.type === 'csp-violation' ? item.body : null);
if (!body) continue;
await env.REPORTS.put(
`csp:${Date.now()}:${crypto.randomUUID()}`,
JSON.stringify(body),
{ expirationTtl: 60 * 60 * 24 * 30 }
);
}
return new Response(null, { status: 204 });
},
};
Legacy report-uri fallback
Permalink to "Legacy report-uri fallback" Safari and older engines never send application/reports+json; they POST a single application/csp-report object to the report-uri URL. The canonical collector above already normalises this shape, but if you run a separate legacy endpoint, parse it explicitly:
app.post(
'/csp-legacy',
express.json({ type: ['application/csp-report', 'application/json'] }),
(req, res) => {
const r = req.body['csp-report'];
if (r) {
persist({
directive: r['effective-directive'] ?? r['violated-directive'],
blockedURL: r['blocked-uri'],
documentURL: r['document-uri'],
disposition: 'enforce',
userAgent: req.get('user-agent'),
at: new Date().toISOString(),
});
}
res.sendStatus(204);
}
);
Gotchas and Edge Cases
Permalink to "Gotchas and Edge Cases"-
report-tois inert withoutReporting-Endpoints. The directive references a name; if noReporting-Endpointsheader declares that name on the same response, the browser has nothing to resolve and silently delivers nothing. Always ship the two headers together and confirm the names match exactly — they are case-sensitive. -
Reports are batched and delayed, never real-time. The Reporting API queues reports and flushes opportunistically — often on the next navigation or when the tab is backgrounded — so a report can land seconds to minutes after the violation. Build dashboards and alerts on rolling windows of 5 minutes or more; never assume immediate delivery.
-
Keep
report-urieven though it is deprecated. It is the only reporting directive Safari and older Chromium/Firefox builds honor. Dropping it blinds you to a large share of real traffic. Retire it only when analytics show the non-Reporting-API share is negligible. -
Register the right content-type parser. A default
express.json()parses onlyapplication/jsonand silently discards theapplication/reports+jsonandapplication/csp-reportbodies, leaving you logging empty objects. Register both types explicitly, as shown above. -
A cross-origin collector needs a CORS preflight. If the collector is on a different origin from the site, the browser preflights the
POST. AnswerOPTIONSwithAccess-Control-Allow-Origin,-Methods: POST, and-Headers: Content-Type, or every report is dropped before its body is sent.
Verification Steps
Permalink to "Verification Steps"1. Confirm both headers are present
Permalink to "1. Confirm both headers are present"curl -sI https://www.example.com/checkout | grep -iE 'reporting-endpoints|content-security-policy'
Expected — both headers, with the report-to name matching a key in Reporting-Endpoints:
reporting-endpoints: csp="https://reports.example.com/csp"
content-security-policy: default-src 'self'; …; report-to csp
2. POST a synthetic report to the collector
Permalink to "2. POST a synthetic report to the collector"Simulate what the browser sends and confirm the collector accepts and stores it:
curl -si -X POST https://reports.example.com/csp \
-H 'Content-Type: application/reports+json' \
-d '[{"type":"csp-violation","user_agent":"curl","body":{"effectiveDirective":"script-src","blockedURL":"https://cdn.evil.example/x.js","disposition":"enforce","documentURL":"https://www.example.com/"}}]'
Expected response and stored record:
HTTP/2 204
{"directive":"script-src","blockedURL":"https://cdn.evil.example/x.js","disposition":"enforce","documentURL":"https://www.example.com/","sample":null,"userAgent":"curl","at":"2026-07-09T12:00:00.000Z"}
3. Trigger a real violation in the browser
Permalink to "3. Trigger a real violation in the browser"Load a page that references a blocked origin, open DevTools, and confirm the console shows the refusal. Because delivery is batched, navigate once more (or background the tab) to prompt a flush, then confirm a matching record appears in your store within a minute:
Refused to load the script 'https://cdn.evil.example/x.js' because it violates the
following Content Security Policy directive: "script-src 'self' https://cdn.example.com".
4. Gate CI on collector health
Permalink to "4. Gate CI on collector health"Add a smoke check to your pipeline that POSTs a synthetic report and asserts a 204, so a broken collector fails the deploy rather than silently dropping production telemetry:
# GitHub Actions
- name: CSP collector smoke test
run: |
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$COLLECTOR_URL" \
-H 'Content-Type: application/reports+json' \
-d '[{"type":"csp-violation","body":{"effectiveDirective":"script-src"}}]')
test "$code" = "204" || { echo "collector returned $code"; exit 1; }
Related
Permalink to "Related"- Security Reporting & Violation Telemetry — the parent workflow covering endpoints, dashboards, alerting, and Report-Only rollout around this collector
- Configuring Content Security Policy with SRI — authors the
report-toandrequire-sri-fordirectives whose reports this collector receives - Handling SRI Failures with onerror Handlers — the client-side capture pattern for SRI failures that never reach the CSP report stream