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)
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)
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();
}
);
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:
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.
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:
-
Deduplicate — Group by page + directive + blocked-uri. You don’t need to see the same violation 10,000 times.
-
Prioritize — Focus on script-src violations first (XSS risk), then connect-src (data exfiltration), then everything else.
-
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.
-
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:
- Week 1-2: Deploy strict policy in report-only mode. Accept the chaos.
- Week 3: Analyze reports. Categorize by severity and frequency.
- Week 4: Fix code issues (move inline scripts, remove dead third-party integrations). Update policy to allow legitimate resources.
- Week 5: Deploy updated policy in report-only mode again. Verify violations dropped.
- Week 6: Deploy enforcement header alongside report-only. Keep both.
- Week 7+: Monitor. Remove report-only only after enforcement is stable for a month.