CSP Reporting: How to Monitor Violations Without Breaking Your Site

Table of Contents

You can’t fix what you can’t see. CSP reporting is how you see what’s happening with your Content Security Policy in production — without blocking anything.

Think of it as a canary in a coal mine. The canary doesn’t prevent problems. It tells you about them early enough to do something about it.

report-uri vs report-to

There are two ways to receive CSP violation reports. Confusingly, they coexist and serve slightly different purposes.

report-uri (Legacy but Reliable)

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

This has been around since CSP was first introduced. It’s supported by every browser that supports CSP. The browser sends a POST request to the specified URL with a JSON body containing the violation details.

report-to (Modern but Inconsistent)

Report-To: {
  "group": "csp-violations",
  "max_age": 31536000,
  "endpoints": [{"url": "https://yoursite.com/api/reports"}]
}

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

This is the newer Reporting API. It’s designed to be more general (not just CSP — any kind of report). The problem is browser support is inconsistent, and the format is different from report-uri.

My recommendation: use both. report-uri for reliability, report-to for future-proofing. They can coexist in the same policy.

Setting Up a Report Endpoint

The violation report is a POST request with content type application/csp-report and a JSON body like this:

{
  "csp-report": {
    "document-uri": "https://yoursite.com/page",
    "referrer": "https://google.com/",
    "violated-directive": "script-src 'self'",
    "effective-directive": "script-src",
    "original-policy": "default-src 'self'; script-src 'self'",
    "disposition": "report",
    "blocked-uri": "inline",
    "line-number": 42,
    "source-file": "https://yoursite.com/page",
    "status-code": 200,
    "script-sample": ""
  }
}

Here’s how to handle this in different frameworks:

Express.js

const express = require('express');
const app = express();

// CSP reports use a specific content type
app.post('/api/csp-report', 
  express.json({ type: 'application/csp-report' }),
  (req, res) => {
    const report = req.body['csp-report'];
    
    console.error('[CSP Violation]', {
      page: report['document-uri'],
      directive: report['violated-directive'],
      blocked: report['blocked-uri'],
      source: report['source-file'],
      line: report['line-number'],
    });
    
    res.status(204).end();
  }
);
```text

### Laravel

Route::post(’/csp-report’, function (Request $request) { $report = json_decode($request->getContent(), true); $csp = $report[‘csp-report’] ?? [];

Log::channel('csp')->warning('CSP Violation', [
    'page' => $csp['document-uri'] ?? 'unknown',
    'directive' => $csp['violated-directive'] ?? 'unknown',
    'blocked' => $csp['blocked-uri'] ?? 'unknown',
    'source' => $csp['source-file'] ?? 'unknown',
    'line' => $csp['line-number'] ?? null,
]);

return response('', 204);

});



### Django

from django.views.decorators.csrf import csrf_exempt from django.http import JsonResponse import json import logging

logger = logging.getLogger(‘csp’)

@csrf_exempt def csp_report(request): if request.method == ‘POST’: report = json.loads(request.body) csp = report.get(‘csp-report’, {}) logger.warning(‘CSP Violation’, extra={ ‘page’: csp.get(‘document-uri’), ‘directive’: csp.get(‘violated-directive’), ‘blocked’: csp.get(‘blocked-uri’), }) return JsonResponse({}, status=204)


## Third-Party Report Services

If you don't want to build your own endpoint, there are hosted solutions:

### Report URI (report-uri.com)

The most popular option. Free tier gives you basic reporting for one domain. Paid plans add more domains and longer retention.

Setup is just adding their URL to your policy:

report-uri https://your-id.report-uri.com/r/d/csp/enforce


### Sentry

If you already use Sentry for error tracking, it can collect CSP reports. Reports show up alongside your JavaScript errors, which gives you useful context.

### Axiom / Logtail

Log management services that can accept CSP reports. Good if you're already sending logs there.

## Report-Only Mode: Your Best Friend

The `Content-Security-Policy-Report-Only` header is the same as the regular CSP header, except it doesn't block anything. It only reports.

Content-Security-Policy-Report-Only: default-src ‘self’; script-src ‘self’; report-uri /api/csp-report


Always start here. Always. I don't care if your policy looks perfect on paper. Real-world pages load things you forgot about. Report-only mode catches those things without breaking your site.

## Dedicating Reports

You'll get a LOT of reports. Most of them will be the same violation repeated thousands of times. Here's how to handle the volume:

1. **Deduplicate** — Group by page + directive + blocked-uri. You don't need to see the same violation 10,000 times.

2. **Prioritize** — Focus on script-src violations first (XSS risk), then connect-src (data exfiltration), then everything else.

3. **Set up alerts** — Get notified when NEW types of violations appear, not when volume increases. A sudden spike in existing violations usually means a bot scanner found your site, not a real attack.

4. **Set a retention period** — Keep reports for 30-90 days. Beyond that, the data is noise.

## The Deployment Flow That Works

Here's the approach I've seen work consistently:

1. **Week 1-2:** Deploy strict policy in report-only mode. Accept the chaos.
2. **Week 3:** Analyze reports. Categorize by severity and frequency.
3. **Week 4:** Fix code issues (move inline scripts, remove dead third-party integrations). Update policy to allow legitimate resources.
4. **Week 5:** Deploy updated policy in report-only mode again. Verify violations dropped.
5. **Week 6:** Deploy enforcement header alongside report-only. Keep both.
6. **Week 7+:** Monitor. Remove report-only only after enforcement is stable for a month.

## Check Your Configuration