Migrating CSP from report-uri to report-to

This guide is part of the Content-Security-Policy (CSP) reference and covers moving CSP violation reporting from the deprecated report-uri directive to the modern report-to directive backed by the Reporting API. report-uri fires one JSON POST per violation directly to a URL. report-to names an endpoint group that the browser registers from a Reporting-Endpoints header, then batches, queues, and retries deliveries to it. The migration is additive and reversible: run both directives in parallel, confirm the new pipeline receives reports, then drop the legacy one. Do this without breaking reporting on browsers that only understand report-uri.

Configuration Syntax & Exact Values

The modern setup needs two pieces: a Reporting-Endpoints header that maps an endpoint name to a collector URL, and a report-to directive in the CSP that references that name. Keep report-uri during the transition.

Reporting-Endpoints: csp-endpoint="https://collector.example.com/csp"
Content-Security-Policy: default-src 'self'; script-src 'self'; report-uri https://collector.example.com/csp; report-to csp-endpoint

Annotated breakdown:

A note on the older Report-To header: the original Reporting API used a Report-To header carrying a JSON object with group, max_age, and endpoints[]. It is superseded by the simpler Reporting-Endpoints header. The CSP report-to directive references the group name from either header, so choose Reporting-Endpoints for new work and only fall back to Report-To if you must support a browser matrix that requires it.

report-uri to report-to migration timeline Three phases: legacy report-uri only, a dual phase running report-uri and report-to in parallel, then report-to only after legacy traffic falls below threshold. Phase 1 report-uri only Phase 2 (dual) report-uri + report-to in parallel Phase 3 report-to only baseline verify new pipeline legacy < 5% traffic
Run report-uri and report-to together through Phase 2; only retire report-uri once legacy-only browsers fall below your traffic threshold.

How the Two Delivery Models Differ

The reason the migration is worth doing is that the two directives deliver reports differently, and that difference shows up under load. report-uri is fire-and-forget: the browser issues one uncredentialed POST to the literal URL the instant a violation occurs, with no batching, no queue, and no retry. A page that trips fifty inline-script violations on first paint sends fifty separate requests, and any that fail — a collector blip, a network drop, a throttled connection — are simply lost.

report-to routes through the Reporting API instead. The browser first registers the named endpoint group from the Reporting-Endpoints header, then funnels violations into a per-origin queue that it flushes in batches, retries with backoff, and can even deliver after the offending page has been closed. That buffering trades a little latency (why reports can lag up to a minute) for far higher delivery reliability and fewer connections.

report-uri direct delivery versus report-to batched delivery report-uri sends one immediate POST per violation with no retry, while report-to registers an endpoint group and delivers reports through a batching, retrying queue. report-uri — direct delivery Browser legacy path one POST per violation no batching, no retry Collector report-to — registered, batched delivery Browser modern path Report queue batch + retry Collector group registered via the Reporting-Endpoints header
report-uri opens a connection per violation with nothing to catch failures; report-to registers an endpoint group and hands reports to a queue that batches and retries delivery.

Server-Side Configuration

Nginx

add_header Reporting-Endpoints 'csp-endpoint="https://collector.example.com/csp"' always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://cdn.example.com; report-uri https://collector.example.com/csp; report-to csp-endpoint" always;

The always flag emits both headers on error responses too, so violations triggered on 4xx/5xx pages still report.

Apache

Header always set Reporting-Endpoints "csp-endpoint=\"https://collector.example.com/csp\""
Header always set Content-Security-Policy "default-src 'self'; script-src 'self' https://cdn.example.com; report-uri https://collector.example.com/csp; report-to csp-endpoint"

Header always set guarantees attachment across all status codes; requires mod_headers.

Cloudflare

export default {
  async fetch(request) {
    const response = await fetch(request);
    const headers = new Headers(response.headers);
    headers.set('Reporting-Endpoints', 'csp-endpoint="https://collector.example.com/csp"');
    headers.set(
      'Content-Security-Policy',
      "default-src 'self'; script-src 'self' https://cdn.example.com; report-uri https://collector.example.com/csp; report-to csp-endpoint"
    );
    return new Response(response.body, { status: response.status, headers });
  },
};

Setting both headers at the edge guarantees delivery even if the origin omits them.

Node/Express (Helmet)

const helmet = require('helmet');

app.use((req, res, next) => {
  res.setHeader('Reporting-Endpoints', 'csp-endpoint="https://collector.example.com/csp"');
  next();
});

app.use(helmet.contentSecurityPolicy({
  useDefaults: false,
  directives: {
    defaultSrc: ["'self'"],
    scriptSrc: ["'self'", "https://cdn.example.com"],
    reportUri: ["https://collector.example.com/csp"],
    'report-to': ["csp-endpoint"],
  },
}));

Helmet has no first-class reportTo key, so pass report-to as a raw directive name and set Reporting-Endpoints in middleware.

Diagnostic & Verification Steps

Confirm both headers ship, then trigger a real violation and watch it reach the collector.

# 1. Both headers present
curl -sI https://example.com | grep -iE 'reporting-endpoints|content-security-policy'

Expected output:

reporting-endpoints: csp-endpoint="https://collector.example.com/csp"
content-security-policy: default-src 'self'; script-src 'self' https://cdn.example.com; report-uri https://collector.example.com/csp; report-to csp-endpoint
# 2. Collector accepts a Reporting API payload (no CSP context needed)
curl -s -o /dev/null -w '%{http_code}\n' -X POST \
  -H 'Content-Type: application/reports+json' \
  -d '[{"type":"csp-violation","age":0,"url":"https://example.com","body":{"documentURL":"https://example.com","effectiveDirective":"script-src","disposition":"enforce"}}]' \
  https://collector.example.com/csp

Expected output: 204 (or 200). A 400/404/415 means the route or Content-Type is wrong; a missing response with a CORS error means the collector lacks Access-Control-Allow-Origin for the preflight.

In the browser: load a page that violates the policy (for example an inline script with no nonce), open DevTools → Network, filter by the collector host, and confirm a POST with Content-Type: application/reports+json whose body contains type: "csp-violation", body.documentURL, body.effectiveDirective, and body.disposition. Reporting API delivery is batched, so allow up to a minute. See the parent CSP reference for the full Report-Only rollout that precedes enforcement.

Edge Cases, Security Implications & Safe Rollback

The dual configuration is safe precisely because each browser resolves to exactly one directive at report time. A browser that implements the Reporting API honors report-to and silently discards the report-uri token it also sees; a browser without the API never parses report-to and falls back to report-uri. With both present, every client lands on a working path and no report is emitted twice from the same event.

How a browser selects a CSP reporting directive A browser that supports the Reporting API uses report-to, one without it uses report-uri, and keeping both directives present covers the entire fleet with no gaps. Reporting API supported? per-browser feature check yes no Use report-to batched via endpoint group Use report-uri direct POST per violation Keep both directives present zero reporting gaps across the fleet
Each browser resolves to a single directive; shipping both is what lets one policy cover modern and legacy clients at once.

Rollback (reversible, non-destructive): remove the Reporting-Endpoints header and the report-to token from the CSP, leaving report-uri intact, then reload the service.

# Nginx: delete the Reporting-Endpoints line and drop "; report-to csp-endpoint" from the CSP, then:
# nginx -t && systemctl reload nginx
# Apache:
Header unset Reporting-Endpoints
# remove "; report-to csp-endpoint" from the Content-Security-Policy value, then apachectl -t && systemctl reload apache2

Re-run the curl -sI check and confirm only report-uri remains. Reporting continues via the legacy path with no downtime.

Frequently Asked Questions

Will I get duplicate reports while both directives are live? Generally no. Any browser that supports the Reporting API uses report-to and ignores report-uri; browsers without it use report-uri. Duplicates usually come from proxy retries or mixed report formats, not the dual directive itself — deduplicate server-side by documentURL + effectiveDirective.

Do I need Reporting-Endpoints or the older Report-To header? Use Reporting-Endpoints (name="url" syntax) for new deployments — it is the Reporting API v1 header and is simpler. Only use the older Report-To JSON header if your required browser matrix predates Reporting-Endpoints support.

Why are reports not arriving even though the header is correct? The Reporting API batches and delays delivery, so allow up to a minute. Confirm the collector returns 204/200 to a manual POST, that it serves over HTTPS, and that the endpoint name in report-to exactly matches the key in Reporting-Endpoints.

Can I point report-uri and report-to at the same collector? Yes. A single endpoint can accept both the legacy csp-report envelope and the Reporting API reports+json array — branch on Content-Type and the body shape. This keeps the dual phase to one collector.

Conclusion

Roll this out incrementally: add Reporting-Endpoints and report-to alongside the existing report-uri in staging, verify the collector receives Reporting API payloads, then promote the dual configuration to production. Hold the dual phase until legacy-only browsers fall below your traffic threshold, then remove report-uri and keep only report-to with Reporting-Endpoints.