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-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.

  • 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. 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; }
Permalink to "Related"
Security Reporting & Violation Telemetry Runtime Policy Enforcement & T…