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