Setting Up CSP Monitoring and Alerting
Table of Contents
Content Security Policy without monitoring is basically wishful thinking.
You can ship a nice-looking CSP header, feel good about it, and still have no idea when a third-party script changes behavior, an inline script starts getting blocked in production, or someone is actively probing your app with injected payloads.
If you care about CSP, you need a feedback loop:
- send violation reports somewhere
- normalize the junk
- alert on the stuff that matters
- ignore the browser noise
That’s the whole game.
What CSP monitoring is actually for
CSP monitoring gives you visibility into:
- broken frontend behavior after a policy change
- third-party script drift
- accidental inline script/style usage
- unsafe eval usage sneaking in through dependencies
- real attack attempts like script injection or malicious form posts
- browser extension noise you should not page anyone about
A lot of teams stop at Content-Security-Policy-Report-Only. That’s useful during rollout, but if nobody reads the reports, it’s just decorative security.
Start with a real policy
Here’s a real CSP header from headertest.com:
content-security-policy: default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com; script-src 'self' 'nonce-YmFiNmZhMGItODhlNC00Y2M3LWEwOTEtNDBmMjYzZGIxMTVm' 'strict-dynamic' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com; style-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://*.cookiebot.com https://consent.cookiebot.com; img-src 'self' data: https:; font-src 'self'; connect-src 'self' https://api.headertest.com https://tallycdn.com https://or.headertest.com wss://or.headertest.com https://*.google-analytics.com https://*.googletagmanager.com https://*.cookiebot.com; frame-src 'self' https://consentcdn.cookiebot.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'
That’s a solid example of a modern production policy: strict-ish script handling with a nonce and strict-dynamic, tight object-src, locked-down frame-ancestors, and explicit third-party allowlists.
If you want to sanity check your own headers before wiring up reporting, headertest.com is handy for quickly inspecting what your site actually sends. For ready-made policy patterns, csp-examples.com is worth bookmarking too.
Add reporting directives
There are two reporting mechanisms you’ll see in the wild:
report-uri— older, still widely usedreport-to— newer, tied to the Reporting API, support is messier than people assume
My advice: use both if you want broad coverage.
Example report-only header
Content-Security-Policy-Report-Only:
default-src 'self';
script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic';
object-src 'none';
base-uri 'self';
report-uri https://csp-report.example.com/csp;
report-to csp-endpoint;
And the matching Report-To header:
Report-To: {"group":"csp-endpoint","max_age":10886400,"endpoints":[{"url":"https://csp-report.example.com/reporting-api"}]}
A few notes:
report-urisends classic CSP JSON reports.report-tomay batch reports and include other browser-generated reports.- Some browsers are noisy, inconsistent, or both.
What the reports look like
Classic CSP report payload:
{
"csp-report": {
"document-uri": "https://app.example.com/dashboard",
"referrer": "",
"violated-directive": "script-src-elem",
"effective-directive": "script-src-elem",
"original-policy": "default-src 'self'; script-src 'self' 'nonce-abc' 'strict-dynamic'; object-src 'none'; base-uri 'self'; report-uri https://csp-report.example.com/csp",
"blocked-uri": "https://evil.example/payload.js",
"status-code": 200,
"script-sample": ""
}
}
Reporting API payloads often arrive as an array:
[
{
"age": 10,
"type": "csp-violation",
"url": "https://app.example.com/dashboard",
"user_agent": "Mozilla/5.0 ...",
"body": {
"blockedURL": "inline",
"disposition": "report",
"documentURL": "https://app.example.com/dashboard",
"effectiveDirective": "script-src-elem",
"originalPolicy": "default-src 'self'; ...",
"referrer": "",
"sample": "alert(1)",
"statusCode": 200
}
}
]
You need to support both or you’ll lose data.
Build a report collector
Don’t send CSP reports to your main app. Keep them isolated. They’re untrusted input, high volume, and mostly operational telemetry.
Here’s a minimal Express collector:
import express from "express";
import crypto from "crypto";
const app = express();
// Browsers use odd content types here, so keep it flexible
app.use(express.json({ type: ["application/json", "application/csp-report", "application/reports+json"] }));
function normalizeClassicReport(payload, req) {
const report = payload["csp-report"] || {};
return [{
source: "report-uri",
receivedAt: new Date().toISOString(),
ip: req.headers["x-forwarded-for"] || req.socket.remoteAddress,
userAgent: req.headers["user-agent"] || "",
documentURL: report["document-uri"] || "",
blockedURL: report["blocked-uri"] || "",
effectiveDirective: report["effective-directive"] || report["violated-directive"] || "",
violatedDirective: report["violated-directive"] || "",
originalPolicy: report["original-policy"] || "",
disposition: "report",
sample: report["script-sample"] || "",
statusCode: report["status-code"] || 0
}];
}
function normalizeReportingApi(payload, req) {
if (!Array.isArray(payload)) return [];
return payload
.filter(item => item.type === "csp-violation")
.map(item => ({
source: "report-to",
receivedAt: new Date().toISOString(),
ip: req.headers["x-forwarded-for"] || req.socket.remoteAddress,
userAgent: item.user_agent || req.headers["user-agent"] || "",
documentURL: item.body?.documentURL || item.url || "",
blockedURL: item.body?.blockedURL || "",
effectiveDirective: item.body?.effectiveDirective || "",
violatedDirective: item.body?.effectiveDirective || "",
originalPolicy: item.body?.originalPolicy || "",
disposition: item.body?.disposition || "report",
sample: item.body?.sample || "",
statusCode: item.body?.statusCode || 0
}));
}
function fingerprint(event) {
return crypto
.createHash("sha256")
.update([
event.documentURL,
event.blockedURL,
event.effectiveDirective,
event.sample
].join("|"))
.digest("hex");
}
app.post("/csp", async (req, res) => {
let events = [];
if (req.body?.["csp-report"]) {
events = normalizeClassicReport(req.body, req);
} else if (Array.isArray(req.body)) {
events = normalizeReportingApi(req.body, req);
}
for (const event of events) {
event.fingerprint = fingerprint(event);
// Replace this with your queue, DB, or log pipeline
console.log(JSON.stringify(event));
}
res.status(204).end();
});
app.listen(3000, () => {
console.log("CSP collector listening on :3000");
});
This is enough to get data flowing. It’s not enough for production.
Store less, but store the right things
Raw CSP reports are messy. You’ll get:
- browser extensions injecting junk
- mobile webviews doing weird things
- stale clients with old HTML
- ad blockers creating false positives
- actual app regressions
- actual attack traffic
If you dump everything into a database forever, you’ll build an expensive pile of nonsense.
I usually normalize reports into something like this:
{
"ts": "2026-08-31T12:00:00Z",
"env": "prod",
"app": "frontend",
"document_url": "https://app.example.com/settings",
"effective_directive": "script-src-elem",
"blocked_url": "inline",
"source_host": "app.example.com",
"blocked_host": "",
"disposition": "enforce",
"sample": "",
"user_agent": "Mozilla/5.0 ...",
"fingerprint": "6c5d..."
}
Then aggregate by:
effective_directivedocument_urlpathblocked_hostdispositionfingerprint
That gives you usable dashboards.
Filter the obvious garbage
You need filters or your alerts will become comedy.
Common ignore rules:
blocked-uriorblockedURLvalues from browser extensionschrome-extension://,moz-extension://,safari-extension://- reports with no useful directive
- old duplicate reports from the same fingerprint
- known bot user agents
Example filter layer:
function shouldIgnore(event) {
const blocked = event.blockedURL || "";
const ignoredSchemes = [
"chrome-extension://",
"moz-extension://",
"safari-extension://"
];
if (ignoredSchemes.some(scheme => blocked.startsWith(scheme))) {
return true;
}
if (!event.effectiveDirective) {
return true;
}
return false;
}
Then use it before storage:
for (const event of events) {
event.fingerprint = fingerprint(event);
if (shouldIgnore(event)) continue;
console.log(JSON.stringify(event));
}
You can get fancier later. Early on, simple noise suppression is enough.
Alert on changes, not every event
The worst possible setup is “send Slack alert for each CSP violation.”
You’ll mute it in an hour.
Alert on:
- a new fingerprint in production
- a sudden spike for an existing fingerprint
- violations in enforce mode
- violations affecting checkout, login, auth callback, or admin pages
form-action,frame-ancestors,base-uri, orscript-srcviolations- blocked hosts that look external or suspicious
Here’s a simple Slack notifier:
async function sendSlackAlert(event) {
const webhook = process.env.SLACK_WEBHOOK_URL;
const text = [
`🚨 New CSP violation`,
`Directive: ${event.effectiveDirective}`,
`Document: ${event.documentURL}`,
`Blocked: ${event.blockedURL || "inline"}`,
`Disposition: ${event.disposition}`,
`Fingerprint: ${event.fingerprint}`
].join("\n");
await fetch(webhook, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ text })
});
}
In production, only call that when the fingerprint is first seen in a time window:
const seen = new Map();
function shouldAlert(event) {
const key = event.fingerprint;
const now = Date.now();
const ttl = 1000 * 60 * 60; // 1 hour
const lastSeen = seen.get(key);
if (!lastSeen || now - lastSeen > ttl) {
seen.set(key, now);
return true;
}
return false;
}
This in-memory approach is fine for a demo. Real systems should use Redis, your SIEM, or a metrics backend.
Good dashboards to build
I like a dashboard with:
- top violated directives
- top blocked hosts
- top affected routes
- report-only vs enforce counts
- new fingerprints over time
- violations by release version
That last one matters. If you tag reports with your app version or deployment ID, you can answer the only question that matters during an incident:
“Did our deploy break this?”
If yes, rollback or patch. If no, it’s probably noise or probing.
Rollout strategy that doesn’t hurt
My preferred order:
- ship policy in
Report-Only - collect reports for at least a week
- remove extension noise
- fix legitimate app issues
- move stable directives to enforcing mode
- keep monitoring forever
Don’t wait for “perfect” before enforcing. You’ll never get there, especially with third-party scripts in the mix.
What deserves a real incident
Not every violation is an attack. Most aren’t.
I’d escalate these quickly:
- repeated
script-srcviolations with suspicious external domains form-actionviolations on login or payment pagesframe-ancestorsviolations suggesting clickjacking attempts- inline script samples that look like injected payloads
- spikes immediately after a dependency or tag manager change
If the sample says something like alert(1) or contains obvious HTML/JS payload fragments, somebody is poking at your app. That’s useful signal even if CSP blocked it.
Final practical advice
A few things I’ve learned the hard way:
- Don’t overreact to extension noise.
- Don’t dump reports straight into email.
- Don’t trust one browser’s reporting behavior to match another.
- Don’t keep CSP static for months while your frontend changes weekly.
- Do treat CSP monitoring like application telemetry, not just security telemetry.
A good CSP setup is alive. Policies evolve. Third parties change. Frontends drift. Monitoring is what keeps CSP from becoming stale config nobody believes in.
If you already have a CSP and no report pipeline, that’s the next thing I’d fix. It’s usually a small amount of code, and it pays for itself the first time it catches a broken deploy or a sketchy injection attempt.