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.

Report delivery sequence Time flows downward between two lifelines. The browser builds a report object when a script-src violation occurs, queues it without sending, then later POSTs an application/reports+json batch to the collector, which normalises both body shapes, persists each violation, and answers 204 No Content. A note explains that batching adds seconds to minutes of delay. Browser Collector endpoint Violation: script-src report object assembled Queued, not sent yet flush deferred POST application/reports+json Normalise both shapes persist each violation 204 No Content Batching adds seconds to minutes before a report lands

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:

Anatomy of the endpoint name binding The Reporting-Endpoints header is split into three parts: the header name, the endpoint label csp, and the quoted collector URL. Below it the Content-Security-Policy header ends with report-to followed by the same label. A connector joins the two labels and is annotated must match. Endpoint declaration Reporting-Endpoints: csp ="https://reports.example.com/csp" collector URL, HTTPS only must match Policy routing Content-Security-Policy: script-src 'self'; report-to csp a bare token, never a URL and never quoted

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:

Body-shape normalisation decision tree A POST arriving at the collector is tested with Array.isArray. An array is the modern batch, whose items carry type csp-violation and a body object to persist. A plain object is the legacy shape, whose csp-report key holds the record to persist. Anything else is skipped while still answering 204. POST /csp received Array.isArray(body)? yes no Modern batch iterate every queued report Legacy single object one violation per request type is csp-violation persist item.body csp-report key present persist that object Any other shape: skip the item and still answer 204

Gotchas and Edge Cases

Permalink to "Gotchas and Edge Cases"
  • report-to is inert without Reporting-Endpoints. The directive references a name; if no Reporting-Endpoints header 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-uri even 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 only application/json and silently discards the application/reports+json and application/csp-report bodies, leaving you logging empty objects. Register both types explicitly, as shown above.

  • The endpoint must be HTTPS and the document a secure context. Reporting-Endpoints is 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, so report-to has nothing to point at even though both headers look correct in DevTools. A local http://localhost origin 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. Answer OPTIONS with Access-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.

Permalink to "Related"

Related Articles

Alerting on SRI Failures from CSP Reports
Security Reporting & Violation Telemetry Runtime Policy Enforcement & T…