A Cloudflare Worker that accepts CSP violation reports from browsers, deduplicates them, stores them in Workers KV, and forwards notifications via webhooks and pluggable email providers.
- Dual-format ingestion — accepts both legacy
report-uri(application/csp-report) and modern Reporting API v1report-to(application/reports+json) formats - Deduplication — SHA-256 fingerprint-based suppression window prevents notification floods from repeated violations
- KV storage — all reports stored with configurable TTL, retrievable via authenticated API
- Webhook notifications — fire-and-forget POST to Slack, Discord, or any HTTP endpoint
- Pluggable email providers — send via Mailgun, AWS SES, Resend, or Cloudflare Email Workers
- Edge-native — runs entirely on Cloudflare's edge with no Node.js dependencies
git clone https://github.com/unredacted/csp-report-worker.git
cd csp-report-worker
npm installcp wrangler-example.toml wrangler.tomlNote:
wrangler.tomlis gitignored. Your local config (with real KV namespace IDs, webhook URLs, etc.) will not be committed.
npx wrangler kv namespace create CSP_REPORTSCopy the output ID into your wrangler.toml:
[[kv_namespaces]]
binding = "CSP_REPORTS"
id = "paste-your-id-here"Tip: The worker dynamically auto-discovers the KV namespace at runtime, so the
bindingname can be whatever you prefer (e.g.KV,STORAGE,CSP_REPORTS).
Edit your wrangler.toml to set notification targets:
[vars]
NOTIFY_EMAILS = "security@example.com,ops@example.com"
NOTIFY_WEBHOOKS = "https://hooks.slack.com/services/T.../B.../xxx"
EMAIL_FROM = "csp-reports@yourdomain.com"
EMAIL_PROVIDER = "mailgun" # or "ses", "resend", "cloudflare"
DEDUP_WINDOW_MINUTES = "60"
KV_TTL_SECONDS = "604800"
ALLOWED_ORIGINS = ""npx wrangler secret put API_TOKENThis token is used to authenticate GET /reports API requests.
npm run deployBy default the worker is available at https://csp-report-worker.<your-subdomain>.workers.dev. To serve it on your own domain (e.g. csp.yourdomain.com):
- Ensure the domain is on a Cloudflare zone in your account
- Add a custom domain route in your
wrangler.toml:
[[routes]]
pattern = "csp.yourdomain.com/*"
custom_domain = true- Redeploy with
npm run deploy— Wrangler will automatically create the DNS record
Tip: You can also use route patterns if you prefer to manage DNS manually. See the examples in
wrangler-example.toml.
Point your site's CSP header at the worker (replace the URL with your custom domain or workers.dev address):
Content-Security-Policy: default-src 'self'; script-src 'self'; report-uri https://csp.yourdomain.com/report
For the modern Reporting API:
Content-Security-Policy: default-src 'self'; script-src 'self'
Reporting-Endpoints: csp-endpoint="https://csp.yourdomain.com/report"
Content-Security-Policy: default-src 'self'; script-src 'self'; report-to csp-endpoint
| Method | Path | Purpose |
|---|---|---|
POST |
/report |
Primary ingestion endpoint |
POST |
/report/csp |
Alias (for report-uri convention) |
Both return 204 No Content on success. Browsers expect this and do not read the body.
| Method | Path | Purpose |
|---|---|---|
GET |
/health |
Returns 204 — uptime checks |
All GET endpoints require Authorization: Bearer <API_TOKEN>.
List recent reports (newest first).
| Parameter | Default | Description |
|---|---|---|
limit |
50 |
Number of reports (max 200) |
cursor |
— | Pagination cursor from previous response |
directive |
— | Filter by violated directive (e.g. script-src) |
curl -H "Authorization: Bearer YOUR_TOKEN" \
https://your-worker.workers.dev/reports?limit=10&directive=script-srcResponse:
{
"reports": [{ "id": "...", "violatedDirective": "script-src", ... }],
"cursor": "..."
}Fetch a single report by its SHA-256 ID.
curl -H "Authorization: Bearer YOUR_TOKEN" \
https://your-worker.workers.dev/reports/abc123...Browser Worker External
│ │ │
├─POST /report────────────►│ │
│ (csp-report or │──parse + normalise │
│ reports+json) │──compute fingerprint │
│ │──check dedup (KV) │
│◄─── 204 ─────────────────│ │
│ │ │
│ ctx.waitUntil() │
│ │──store report (KV) │
│ │──if new: notify │
│ │ ├──webhook POST ───────────►│
│ │ └──email (provider) ────────►│
The EMAIL_PROVIDER variable selects the email backend. Set it to one of:
| Provider | EMAIL_PROVIDER |
Required Vars | Required Secrets |
|---|---|---|---|
| Mailgun | mailgun |
MAILGUN_DOMAIN, MAILGUN_REGION |
MAILGUN_API_KEY |
| AWS SES | ses |
AWS_SES_REGION |
AWS_SES_ACCESS_KEY_ID, AWS_SES_SECRET_ACCESS_KEY |
| Resend | resend |
— | RESEND_API_KEY |
| Cloudflare | cloudflare |
— | — (uses [[send_email]] binding) |
Leave EMAIL_PROVIDER empty to disable email notifications entirely.
Secrets are set via wrangler secret put <NAME> and are never stored in config files.
| Key pattern | Value | TTL |
|---|---|---|
report:{invertedTs}:{id} |
Full normalised report JSON | KV_TTL_SECONDS |
idx:{id} |
Pointer to primary key | KV_TTL_SECONDS |
dedup:{fingerprint} |
{ count, firstSeen } |
DEDUP_WINDOW_MINUTES × 60 |
Inverted timestamp (9999999999999 - Date.now()) ensures KV's lexicographic list() returns newest reports first, enabling efficient cursor-based pagination.
The fingerprint is a SHA-256 hash of:
blockedUri | violatedDirective | documentUri | sourceFile:lineNumber
This groups identical violations. When a report's fingerprint is seen for the first time in its dedup window, a notification fires. Subsequent duplicates within the window are stored but do not trigger notifications.
Email is optional — set EMAIL_PROVIDER to enable it. All providers require EMAIL_FROM to be set.
- Create a Mailgun account and verify your sending domain
- Set your vars:
EMAIL_PROVIDER = "mailgun" MAILGUN_DOMAIN = "mg.yourdomain.com" MAILGUN_REGION = "us" # or "eu"
- Set the API key secret:
wrangler secret put MAILGUN_API_KEY
- Verify your sender domain/email in the SES console
- Create an IAM user with
ses:SendEmailpermission - Set your vars:
EMAIL_PROVIDER = "ses" AWS_SES_REGION = "us-east-1"
- Set secrets:
wrangler secret put AWS_SES_ACCESS_KEY_ID wrangler secret put AWS_SES_SECRET_ACCESS_KEY
- Create a Resend account and add your domain
- Set your vars:
EMAIL_PROVIDER = "resend"
- Set the API key secret:
wrangler secret put RESEND_API_KEY
Requires Cloudflare Email Routing on the zone.
- Enable Email Routing and verify destination addresses
- Uncomment the
[[send_email]]binding inwrangler.toml - Set your vars:
EMAIL_PROVIDER = "cloudflare"
# Run locally
npm run dev
# Run tests
npm test
# Type check
npm run typecheck
# Generate wrangler types
npm run typesTests use @cloudflare/vitest-pool-workers which runs inside the workerd runtime with real KV bindings (via Miniflare).
npm testcsp-report-worker/
├── src/
│ ├── index.ts # Worker entrypoint, router
│ ├── ingest.ts # Parse + normalise incoming reports
│ ├── dedup.ts # Fingerprint + KV dedup logic
│ ├── store.ts # KV read/write for reports
│ ├── config.ts # Environment variable parsing
│ ├── auth.ts # Bearer token check
│ ├── api.ts # GET /reports handlers
│ ├── types.ts # Shared TypeScript types
│ └── notify/
│ ├── index.ts # Notification orchestrator
│ ├── email.ts # Email dispatch (provider-agnostic)
│ ├── provider.ts # Email provider interface + factory
│ ├── webhook.ts # Generic webhook POST
│ └── format.ts # Plain text + HTML + Slack formatters
├── test/
│ ├── ingest.test.ts
│ ├── dedup.test.ts
│ ├── format.test.ts
│ ├── email.test.ts
│ └── api.test.ts
├── wrangler-example.toml # Template — copy to wrangler.toml
├── wrangler.toml # Your local config (gitignored)
├── package.json
├── tsconfig.json
├── vitest.config.ts
├── env.d.ts
├── AGENTS.md # AI agent guidelines
└── LICENSE # GPL-3.0
GPL-3.0-or-later. See LICENSE.