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.
Nothing about that pipeline is synchronous with the violation itself. The queue lives in the browser process, survives across navigations within the same origin, and drains when the user agent decides it is cheap to do so. Every report carries an age field in milliseconds telling you how long it sat in that queue, and a disposition of enforce or report telling you whether the resource was actually blocked — the field that makes Rolling Out a Script Policy in Report-Only Mode measurable, because the same collector receives both dispositions and you filter on that key rather than standing up a second endpoint.
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
The only thing binding those two headers together is the bare token csp. It is a label you choose, not a keyword, and the browser resolves it by exact string comparison on the same response — rename it in one header and reporting goes silent with no console warning:
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);
}
);
Whichever endpoint you run, the branch every collector needs is the same: decide what shape arrived before touching any field. Never trust the request’s content-type alone to make that call — proxies rewrite it, synthetic tests set it by hand, and at least one browser has shipped application/json for a legacy report-uri POST. Branch on the parsed value instead, and treat an unrecognised shape as a skip rather than an error, so a malformed body never turns into a 500 that the browser will not retry:
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. -
The endpoint must be HTTPS and the document a secure context.
Reporting-Endpointsis only honored on secure origins, and an endpoint URL that is not HTTPS is discarded when the header is parsed rather than at delivery time. The failure is total and silent: the name never resolves, soreport-tohas nothing to point at even though both headers look correct in DevTools. A localhttp://localhostorigin is treated as secure, which is why a setup that works in development can deliver nothing from a staging box served over plain HTTP. -
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. The console message proves only that the policy fired; it says nothing about whether the report was queued or where it will go. Because delivery is batched, navigate once more (or background the tab) to prompt a flush, then watch the Network panel for a POST to your endpoint — Chromium hides these under the request type Other, and they carry no referrer. 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. A reporting endpoint that returns 200 to curl but has quietly stopped writing records is one of the standard findings when Auditing a Script Policy for Gaps, because zero violations reads identically to a healthy policy:
# 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; }
Frequently Asked Questions
Permalink to "Frequently Asked Questions"What content-type does the Reporting API POST?
The modern Reporting API posts a JSON array with content-type application/reports+json. The deprecated report-uri directive posts a single object with content-type application/csp-report. A collector that supports both browsers must register parsers for both content-types.
Do I still need report-uri if I use report-to?
Yes, until your non-Reporting-API traffic is negligible. Safari and older Chromium and Firefox builds ignore report-to and Reporting-Endpoints entirely; report-uri is the only directive they honor. Ship both and let analytics tell you when it is safe to drop the legacy one.
Why is my report delayed by several seconds?
The Reporting API batches reports and flushes them opportunistically rather than in real time, often on the next navigation or when the tab is backgrounded. Delays of seconds to minutes are normal. Build alerting on rolling windows, never on real-time delivery assumptions.
Does an SRI hash mismatch produce a CSP violation report?
No. A hash mismatch is an integrity failure, not a policy violation: the browser blocks the resource and fires an error event on the element, but queues nothing for the reporting endpoint. Only a missing integrity attribute under require-sri-for produces a report, and support for that directive is narrow. Capture mismatches client-side with an onerror handler and POST them to the same collector.
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
- Alerting on SRI Failures from CSP Reports — what to do with the records this collector writes: thresholds, deduplication, and paging rules