Security Reporting & Violation Telemetry

Permalink to "Security Reporting & Violation Telemetry"

This workflow is part of Runtime Policy Enforcement & Trusted Types. A Content Security Policy, an integrity attribute, or a Trusted Types directive that silently blocks a resource in a user’s browser is worthless to you if you never learn it fired — the user sees broken functionality and you see nothing until a support ticket arrives days later. Violation telemetry closes that gap: it turns every blocked script, failed hash check, and rejected DOM sink into a structured event you can dashboard, alert on, and gate deployments against.

The workflow on this page wires the browser-native Reporting API to a collector you control. It covers declaring endpoints, routing CSP, SRI, and Trusted Types violations to them, building the collector, computing a mismatch rate, alerting on anomalies, and rolling the whole thing out safely in Report-Only mode first. The child page Collecting CSP Violation Reports with the Reporting API is the copy-pasteable reference for the collector itself.

Prerequisites

Permalink to "Prerequisites"

Conceptual Foundation: How the Reporting API Delivers Violations

Permalink to "Conceptual Foundation: How the Reporting API Delivers Violations"

The Reporting API (W3C Working Draft, WebAppSec WG) is a general-purpose delivery channel that decouples who generates a report from where it is sent. A policy — CSP, a Document Policy, a deprecation, or a network error — generates a report object. A named endpoint group, declared once in a response header, tells the browser where to POST it. The browser queues reports and flushes them in batches with content-type application/reports+json.

This is a deliberate shift away from the older CSP Level 2 model, where the report-uri directive both generated and addressed reports in a single directive, posting a application/csp-report body to one hard-coded URL. The modern model separates those concerns:

  • Reporting-Endpoints (a response header) declares named endpoint groups: Reporting-Endpoints: csp-endpoint="https://reports.example.com/csp".
  • report-to (a CSP directive) references one of those names: Content-Security-Policy: … ; report-to csp-endpoint.
  • The browser batches matching violations and delivers them to the resolved URL.

A single collector receives every policy class through one pipeline. CSP script-src blocks, require-sri-for failures, and require-trusted-types-for violations all arrive as report objects with "type": "csp-violation", distinguished by the effectiveDirective and disposition fields inside body. That is why one endpoint and one schema can back your entire runtime-policy observability stack.

The diagram below traces a single violation from the browser through the endpoint declaration to your collector and out to alerting.

Reporting API Telemetry Flow A flow diagram showing a browser detecting a CSP, SRI, or Trusted Types violation, batching it to a named report-to endpoint over application/reports+json, a collector persisting the record, and downstream branches to a dashboard and to an alert that fires when the violation rate crosses a threshold. Browser CSP / SRI / TT violation fires batched report-to named endpoint reports+json POST Collector validate + store by directive Dashboard mismatch rate Alert rate > threshold Report-Only: same pipeline, reports fire but nothing is blocked

Step 1 — Define Your Reporting Endpoints

Permalink to "Step 1 — Define Your Reporting Endpoints"

Declare the collector with the Reporting-Endpoints response header. The value is a structured-fields dictionary mapping a name you choose to an HTTPS URL. Emit this header on the same responses that carry your CSP.

Reporting-Endpoints: csp-endpoint="https://reports.example.com/csp",
                     default="https://reports.example.com/default"

The default group is special: the browser routes network-error, deprecation, and intervention reports to it when no more specific group matches. For policy violations you will reference the named csp-endpoint explicitly in Step 2.

To confirm the header is present, request any page and inspect the response:

curl -sI https://www.example.com/ | grep -i reporting-endpoints

Expected output:

reporting-endpoints: csp-endpoint="https://reports.example.com/csp", default="https://reports.example.com/default"

If the header is missing, a report-to directive downstream has nothing to resolve against and no reports will be delivered.


Step 2 — Route CSP, SRI & Trusted Types Violations to the Endpoint

Permalink to "Step 2 — Route CSP, SRI & Trusted Types Violations to the Endpoint"

Add the report-to directive to your CSP and reference the endpoint name from Step 1. Include require-sri-for so SRI failures surface as violation reports, and require-trusted-types-for so DOM-XSS sink violations do too. Keep the legacy report-uri directive alongside report-to for browsers that do not yet support the Reporting API.

Content-Security-Policy:
  default-src 'self';
  script-src 'self' https://cdn.example.com;
  require-sri-for script style;
  require-trusted-types-for 'script';
  report-uri https://reports.example.com/csp-legacy;
  report-to csp-endpoint

Verify the directive round-trips by triggering a deliberate violation. Load a page that references a blocked origin, then watch the browser console:

Refused to load the script 'https://evil.example/x.js' because it violates
the following Content Security Policy directive: "script-src 'self' https://cdn.example.com".

A corresponding report will be queued for csp-endpoint. Because the browser batches delivery, allow up to a minute before checking the collector.


Step 3 — Build the Report Collector

Permalink to "Step 3 — Build the Report Collector"

The collector is a small HTTPS service that accepts POST bodies with content-type application/reports+json, validates the payload, and persists each report. The full annotated implementation — Express, a Cloudflare Worker, and the legacy fallback — lives in Collecting CSP Violation Reports with the Reporting API. The minimal shape:

// collector.js — Express endpoint for the modern Reporting API
import express from 'express';

const app = express();

// The Reporting API posts an array with content-type application/reports+json.
app.post(
  '/csp',
  express.json({ type: ['application/reports+json', 'application/json'] }),
  (req, res) => {
    const reports = Array.isArray(req.body) ? req.body : [req.body];
    for (const report of reports) {
      if (report.type !== 'csp-violation') continue;
      const b = report.body;
      persist({
        directive: b.effectiveDirective,
        blockedURL: b.blockedURL,
        disposition: b.disposition,     // "enforce" or "report"
        documentURL: b.documentURL,
        sample: b.sample ?? null,       // present for Trusted Types
        userAgent: report.user_agent,
        at: new Date().toISOString(),
      });
    }
    res.sendStatus(204);
  }
);

app.listen(8080);

The collector must return a 2xx status — 204 No Content is idiomatic — and do so quickly. A slow or erroring collector causes the browser to drop reports after a retry budget. Expected output when you POST a sample report with curl:

HTTP/1.1 204 No Content

Step 4 — Capture SRI Failures That CSP Does Not Route

Permalink to "Step 4 — Capture SRI Failures That CSP Does Not Route"

A bare SRI hash mismatch on a <script> with no governing CSP produces only a console error and an onerror event — no report is generated. Two mechanisms turn those failures into telemetry.

First, require-sri-for script style (already added in Step 2) makes a missing-or-mismatched integrity surface as a csp-violation report through the same pipeline. Second, for defense in depth and to catch the network-level onerror, attach a capture-phase listener that beacons directly to the collector. This is the same pattern documented in Handling SRI Failures with onerror Handlers, extended to emit telemetry.

// Attach before any SRI-tagged element loads. Capture phase is required
// because error events on resource elements do not bubble.
document.addEventListener(
  'error',
  (e) => {
    const el = e.target;
    if (
      (el instanceof HTMLScriptElement || el instanceof HTMLLinkElement) &&
      el.integrity
    ) {
      navigator.sendBeacon(
        'https://reports.example.com/sri',
        JSON.stringify({
          type: 'sri-failure',
          resource: el.src || el.href,
          integrity: el.integrity,
          release: window.__RELEASE__,
          at: Date.now(),
        })
      );
    }
  },
  true
);

Expected signal: after a forced mismatch (edit one character of a live integrity token), a sri-failure record appears in your store within the beacon flush interval, correlated to the release that shipped the bad hash.


Step 5 — Dashboard the Violation & Mismatch Rate

Permalink to "Step 5 — Dashboard the Violation & Mismatch Rate"

Raw reports are noise until you aggregate them. The single most actionable metric is the mismatch rate: violations for a given asset divided by loads of that asset, over a rolling window, sliced by release. A query over a violations table:

-- Per-directive violation counts for the last 30 minutes, by release
SELECT
  release,
  directive,
  blocked_url,
  count(*) AS violations
FROM violations
WHERE at > now() - interval '30 minutes'
  AND disposition = 'enforce'
GROUP BY release, directive, blocked_url
ORDER BY violations DESC
LIMIT 25;

Expected output — a ranked table that immediately isolates the offending asset and the release that introduced it:

 release  |   directive   |        blocked_url            | violations
----------+---------------+-------------------------------+-----------
 v2.4.1   | script-src    | https://cdn.example.com/a.js  |       412
 v2.4.1   | require-sri-for | https://cdn.example.com/b.js |        87
 v2.4.0   | trusted-types | (inline)                      |         3

Plot violations / loads per asset as a time series. A healthy enforced policy sits at or near zero. A deploy that breaks a hash or trips a Trusted Types sink shows as a step change aligned to the release marker — exactly the signal a deployment gate consumes, in the same spirit as the mismatch-rate gate in CDN Trust Mapping & Routing.


Step 6 — Alert on Anomalies and Roll Out with Report-Only

Permalink to "Step 6 — Alert on Anomalies and Roll Out with Report-Only"

Two alert conditions catch the failure modes that matter:

  1. Rate threshold — the enforced mismatch rate for any asset exceeds 0.5% over a 5-minute window. This catches a bad deploy or a compromised origin.
  2. Novel blocked URL — a blocked_url appears that is not in the current script manifest. This catches an injection attempt or an unaudited third-party tag.
// alert.js — run on a 1-minute schedule against the collector's store
const WINDOW_MIN = 5;
const THRESHOLD = 0.005; // 0.5%

const rows = await db.query(`
  SELECT blocked_url,
         count(*) FILTER (WHERE disposition = 'enforce') AS violations,
         max(loads) AS loads
  FROM violation_rate_view
  WHERE at > now() - interval '${WINDOW_MIN} minutes'
  GROUP BY blocked_url
`);

for (const r of rows) {
  const rate = r.loads ? r.violations / r.loads : 0;
  if (rate > THRESHOLD) {
    await notify(`SRI/CSP mismatch ${(rate * 100).toFixed(2)}% for ${r.blocked_url}`);
  }
}

Before you enforce anything, run the policy in Report-Only mode. The Content-Security-Policy-Report-Only header exercises the entire pipeline — reports fire and reach your collector — but nothing is ever blocked. Ship it to production, watch the dashboard for one to two weeks, and drive the false-positive rate toward zero by allow-listing legitimate assets. Only then swap Content-Security-Policy-Report-Only for the enforcing Content-Security-Policy, keeping every reporting directive in place.

Content-Security-Policy-Report-Only:
  default-src 'self';
  script-src 'self' https://cdn.example.com;
  require-sri-for script style;
  report-to csp-endpoint

Note that reports generated in Report-Only mode carry "disposition": "report"; enforced reports carry "disposition": "enforce". Filter on this field so your rollout dashboard never conflates the two.


Configuration Reference

Permalink to "Configuration Reference"
Option Valid values Notes
Reporting-Endpoints name="https://…" pairs, comma-separated Response header; HTTPS only; the default group receives unmatched report types
report-to (CSP directive) a single endpoint name from Reporting-Endpoints Modern delivery; batched application/reports+json; ignored by browsers without Reporting API support
report-uri (CSP directive) one or more absolute URLs Deprecated but still needed for Safari and older engines; posts application/csp-report
Report content-type (modern) application/reports+json Body is a JSON array of report objects
Report content-type (legacy) application/csp-report Body is a single object under a csp-report key
disposition enforce, report report for Report-Only; filter to separate rollout from production signal
Collector response 2xx (use 204) Non-2xx or slow responses cause the browser to drop queued reports
require-sri-for script, style, script style Routes SRI failures into the CSP report stream
require-trusted-types-for 'script' Routes DOM-sink violations into the same stream, with a sample field

Integration with Adjacent Tooling

Permalink to "Integration with Adjacent Tooling"

Violation telemetry is the observability layer beneath the rest of the runtime-policy stack, and its output feeds several adjacent workflows:

For long-term retention and audit, partition the violation store by day and export daily rollups into your SIEM so security review can correlate spikes with CDN changelog events and dependency updates.


Troubleshooting

Permalink to "Troubleshooting"

Reports never arrive despite a visible console violation

The report-to directive names an endpoint that is not declared in the Reporting-Endpoints header, so the browser has nothing to resolve. Confirm the names match exactly (they are case-sensitive) and that both headers are present on the same response. Re-check with curl -sI and grep for both content-security-policy and reporting-endpoints.

Collector receives requests but the body is empty

The body parser is rejecting the application/reports+json content-type. Express’s default express.json() only parses application/json. Register the parser with an explicit type: ['application/reports+json', 'application/json'] as shown in Step 3, or the request body is discarded and you log empty objects.

Cross-origin collector returns the report but nothing is stored

The browser sent a CORS preflight for the cross-origin POST and your collector did not answer it. The Reporting API requires the collector to respond to the OPTIONS preflight with Access-Control-Allow-Origin, Access-Control-Allow-Methods: POST, and Access-Control-Allow-Headers: Content-Type. Without a successful preflight the browser drops the report silently.

Only some browsers deliver reports

Safari and older Chromium/Firefox builds do not implement report-to/Reporting-Endpoints; they only understand the deprecated report-uri. If you removed report-uri you lost all reporting from those clients. Keep both directives until your analytics show the non-Reporting-API traffic share is negligible.

A flood of identical reports right after deploy, then silence

This is expected batching behavior, not a bug. The Reporting API queues reports and flushes them opportunistically — often on the next navigation or when the tab is backgrounded — so a burst of a single violation may all arrive together, delayed by seconds to minutes. Do not build alerting that assumes real-time delivery; use rolling windows of 5 minutes or more.

Report-Only violations are triggering enforcement alerts

Your alert query is not filtering on disposition. Report-Only reports carry "disposition": "report" and must be excluded from any query that drives enforcement paging, or every rollout will page the on-call.


Security Boundary

Permalink to "Security Boundary"

Violation telemetry tells you that a policy fired and against which asset — it is a detection and observability control, not a prevention control. It does not:

  • Block anything on its own. Reports are informational. Blocking is done by the enforcing CSP, the integrity attribute, or the Trusted Types directive — the reporting layer only observes their decisions.
  • Guarantee delivery. The Reporting API is best-effort and batched; a user who closes the tab before the queue flushes, or who runs an extension that strips the header, never reports. Never treat the absence of reports as proof of the absence of attacks.
  • Authenticate the reporter. Any client can POST a forged application/reports+json body to a public collector. Rate-limit the endpoint, validate the schema, and treat report contents as untrusted input — never render sample or blockedURL unescaped in a dashboard.
  • Protect the build pipeline. A malicious bundle that ships with a matching hash produces no violation. Build provenance requires Provenance Verification Workflows, not runtime reporting.

Frequently asked questions

Permalink to "Frequently asked questions"
What is the difference between report-uri and report-to?

report-uri is the deprecated CSP Level 2 directive that posts a application/csp-report body to a single hard-coded URL. report-to is its replacement: it references a named endpoint group declared through the Reporting-Endpoints header and delivers batched application/reports+json bodies via the modern Reporting API. Ship both during migration — Safari and older browsers still only understand report-uri, while report-to gives you the batching, deduplication, and unified pipeline of the Reporting API.

Why are my violation reports not arriving?

Work through the common causes in order: a report-to name that does not match any entry in Reporting-Endpoints; a collector returning a non-2xx status; a cross-origin collector missing CORS preflight support; a plaintext HTTP endpoint (the API refuses these); or simple batching delay. The Reporting API queues reports and flushes them opportunistically, so allow up to a minute before concluding delivery has failed.

Does the Reporting API capture SRI failures automatically?

Not on its own. A bare SRI mismatch with no governing CSP produces only a console error and an onerror event. To turn SRI failures into telemetry, enforce require-sri-for script style through CSP so the failed load surfaces as a csp-violation report, and optionally attach a capture-phase error listener that beacons the failure directly to your collector for defense in depth.

Should I start in Report-Only mode?

Yes, always. Content-Security-Policy-Report-Only exercises the whole reporting pipeline without blocking anything, so you can measure real-world breakage against real traffic. Run it in production for one to two weeks, drive the false-positive rate to near zero by allow-listing legitimate assets, then swap to the enforcing header while keeping every reporting directive in place.

Can one endpoint receive CSP, SRI, and Trusted Types reports together?

Yes. All three surface through the same Reporting API pipeline as report objects with "type": "csp-violation"require-sri-for and require-trusted-types-for violations included. Your collector switches on body.effectiveDirective and body.disposition to classify them into separate telemetry streams, so one URL and one schema back the entire runtime-policy observability stack.

How much traffic will a public collector receive?

More than you expect, and much of it junk. Browser extensions, injected ad-blockers, and hostile scanners all generate violations against your policy. Budget for the endpoint to absorb spikes, rate-limit per source IP, sample high-volume duplicate reports, and always validate the schema before persisting — treat every field as untrusted input.


Permalink to "Related"

Articles in This Topic

Collecting CSP Violation Reports with the Reporting API
Back to Runtime Policy Enforcement & Trusted Types