Guide

CSP report-uri vs report-to

CSP violation reports tell you what your real users are hitting. Pick the reporting mechanism that matches the browsers you serve, then run a small endpoint that stores what arrives.

Why collect CSP reports

Reports turn a CSP from a static header into feedback. They let you see what the browser is actually blocking, on which pages, and from which scripts, so you can tighten the policy without breaking real users.

  • Catch violations you did not anticipate, including injected third-party tags.
  • Debug a strict policy before flipping from report-only to enforce.
  • Gradually tighten a permissive policy in small, observable steps.
  • Detect regressions after a deploy or a dependency upgrade.

Ship your first policy in Content-Security-Policy-Report-Only with a working endpoint, then switch to Content-Security-Policy once the violation volume is low and stable.

report-uri (legacy)

The original CSP2 mechanism. The browser POSTs a single violation document to a URL you declare in the policy. The directive is simple, but it is no longer the recommended path forward.

Content-Security-Policy: default-src 'self'; report-uri /csp-report

Minimum requirements for the endpoint:

  • Accept POST with a Content-Type of application/csp-report (legacy) or application/reports+json (modern batch).
  • Return a 2xx status, ideally 204 No Content, with no body.
  • Be reachable from the page origin; some browsers require CORS headers when the endpoint is cross-origin.
  • Be fast. A slow or timing-out endpoint is silently retried and can show up in your own logs as a self-inflicted outage.

Deprecation note: report-uri is replaced by report-to in CSP Level 3. It is still supported in current browsers, but new deployments should prefer the Reporting API.

report-to (modern)

The modern mechanism rides on the W3C Reporting API. The browser batches violations for an endpoint over a short window, then ships them as a single POST. The endpoint itself is declared in a separate Report-To header (older browsers) or Reporting-Endpoints header (newer browsers).

Content-Security-Policy: default-src 'self'; report-to csp-endpoint

The report-to directive references an endpoint name, not a URL. The name must be defined in a companion header sent on the same response.

Sample Report-To header

The legacy Report-To header carries a JSON document that names one or more endpoint groups. group matches what report-to references in your CSP, max_age is how long the browser should remember the endpoint, and endpoints lists the URLs that should receive reports.

Report-To: {"group":"csp-endpoint","max_age":10886400,"endpoints":[{"url":"https://example.com/csp-report"}],"include_subdomains":false}

Notes:

  • Set max_age in seconds. 10886400 (about 18 weeks) is a reasonable default.
  • You can list multiple endpoints; the browser picks one per report.
  • The endpoint URL must respond to OPTIONS with the right CORS headers if it is cross-origin.

Sample Reporting-Endpoints header

Newer browsers prefer the structured Reporting-Endpoints header. The value is a comma-separated list of name="url" pairs, with no JSON parsing required.

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

You can also expose the endpoint across subdomains with the ?includeSubdomains query parameter:

Reporting-Endpoints: csp-endpoint="https://example.com/csp-report?includeSubdomains"

For maximum compatibility, send both Report-To and Reporting-Endpoints at the same time. Browsers will pick whichever they understand.

CSP directives to use

Pair the endpoint declaration with a CSP that names it. Most teams ship both directives so the legacy and modern paths both work.

Content-Security-Policy:
  default-src 'self';
  report-uri /csp-report;
  report-to csp-endpoint;

The report-uri URL can be relative (it is resolved against the page URL) and can point at the same endpoint that the Reporting-Endpoints header advertises.

Backend minimum

Whatever you collect reports with, it only needs to do three things: accept the POST, acknowledge with 204, and store the body somewhere you can grep. Three minimal examples:

Node.js (Express)

app.post("/csp-report", (req, res) => {
  console.log("csp-report", JSON.stringify(req.body));
  res.status(204).end();
});

Cloudflare Workers

export default {
  async fetch(request) {
    if (request.method !== "POST") return new Response("Method not allowed", { status: 405 });
    const body = await request.text();
    await env.REPORTS.put(crypto.randomUUID(), body);
    return new Response(null, { status: 204 });
  }
};

Python (Flask)

@app.post("/csp-report")
def csp_report():
    payload = request.get_json(force=True, silent=True)
    app.logger.warning("csp-report %s", payload)
    return "", 204

If the endpoint is on a different origin than the page, respond to OPTIONS preflight with Access-Control-Allow-Origin set to the page origin and Access-Control-Allow-Methods including POST. Without that, the browser will refuse to ship the report.

Verify it end to end

Once the endpoint is up, the fastest way to confirm it works is to fire a real violation payload at it. The CSP Generator on this site has a "Send test violation" button that POSTs a sample application/csp-report document to the URL you provide and shows you the response status and body. If you see a green "Report accepted" badge, your endpoint is wired up correctly and the browser will be able to deliver real reports.

Open the test tool

Official references