Ship CSP as Report-Only first, or you will silently break your analytics
I put a CSP on my site as Report-Only before enforcing it. Within five minutes the logs showed a real violation: analytics requests blocked by connect-src. Had I gone straight to enforce mode, all page-view data would have vanished with no error anywhere.
The usual way to add a CSP is: find a "recommended Next.js CSP", paste it into your Caddy or nginx header block, done.
I was about to do exactly that, but ran it as Report-Only first. Five minutes later:
[csp] connect-src blocked=https://xxxxx.supabase.co/rest/v1/page_views doc=https://sacredlantern.online/
That was the analytics beacon. In enforce mode, every page-view would have stopped being recorded, and nothing on the page would have looked broken.
TL;DR
- CSP violations are silent. Resources get blocked, the page renders fine, nothing surfaces outside the console.
- Report-Only needs a receiving endpoint, otherwise the browser discards reports and your policy is decoration.
- The observation period ends when violations reach zero, not after N days.
Why copying a recommended config breaks things
Most Next.js CSP examples look like this:
default-src 'self';
script-src 'self' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
img-src 'self' data:;
connect-src 'self';
The problem is connect-src 'self'. That directive governs fetch, XHR and sendBeacon. Restricting it to your own origin blocks every third-party data channel:
| Blocked | Why it's common |
|---|---|
| Analytics (Supabase, Plausible, Umami) | Beacon to a different origin |
| Third-party form posts | Typeform, Formspree, Mailchimp |
| Payment SDK handshakes | Parts of Stripe.js |
| Error monitoring (Sentry) | Posts to an ingest domain |
None of these failures are visible. Forms silently don't submit, stats silently stop, errors silently go unreported. The symptom is "my numbers dropped", not "something threw".
Step 1: build the report endpoint first
This is the step people skip. Without report-uri, the browser has nowhere to deliver violations and drops them. At that point Content-Security-Policy-Report-Only is a header nobody reads.
// app/api/csp-report/route.ts
import { NextResponse } from "next/server";
export async function POST(request: Request) {
try {
const body = await request.json();
const reports = Array.isArray(body) ? body : [body];
for (const r of reports.slice(0, 10)) {
const b = r["csp-report"] || r.body || r;
const directive = b["violated-directive"] || b.effectiveDirective || "?";
const blocked = b["blocked-uri"] || b.blockedURL || "?";
const doc = b["document-uri"] || b.documentURL || "?";
console.warn(
`[csp] ${directive} blocked=${String(blocked).slice(0, 120)} doc=${String(doc).slice(0, 120)}`,
);
}
} catch {
// malformed reports must not break the response
}
return new NextResponse(null, { status: 204 });
}
Design notes:
- 204, not 200 — no body needed; 204 stops the browser from retrying.
slice(0, 10)— a hostile request could post tens of thousands of entries.- Handle both shapes — legacy
csp-reportand the newer Reporting API differ. - Swallow parse errors — report formats vary across browsers; a 500 would make the browser give up.
Verify it works:
curl -s -o /dev/null -w "%{http_code}\n" -X POST \
-H "Content-Type: application/json" \
-d '{"csp-report":{"violated-directive":"script-src","blocked-uri":"inline"}}' \
https://your-site.com/api/csp-report
# 204
Step 2: deploy as Report-Only
In Caddy (nginx: add_header):
header {
Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self' https://buy.stripe.com; report-uri /api/csp-report"
}
Why each value:
| Directive | Value | Reason |
|---|---|---|
script-src |
'unsafe-inline' 'unsafe-eval' |
Next.js injects RSC payload inline; removing it blanks the page |
style-src |
'unsafe-inline' |
Tailwind inline styles and style attributes |
img-src |
data: blob: |
Chart and canvas exports |
frame-ancestors |
'none' |
Clickjacking defence, pairs with X-Frame-Options: DENY |
form-action |
'self' https://buy.stripe.com |
Forms post to Stripe checkout |
Note: in Report-Only mode nothing is actually blocked, including frame-ancestors. You're only collecting warnings about what would break.
report-uri is deprecated but has the widest support. The modern pair is report-to plus a Report-To header; Chrome supports it, Safari doesn't fully. I stayed on report-uri — one less endpoint to maintain, and every current browser honours it.
Step 3: read the logs, don't guess
Minutes after deploying:
[csp] connect-src blocked=https://xxxxx.supabase.co/rest/v1/page_views doc=https://sacredlantern.online/
One line was enough to diagnose it. Fix:
connect-src 'self' https://xxxxx.supabase.co
Reload and keep watching.
When to switch to enforce mode
Not "after two weeks". The criteria are:
- No violations pointing at origins you actually use.
- Remaining violations are limited to browser-extension injection (
chrome-extension://) anddata:probing.
Category 2 comes from the user's environment, not your site. Ignore it.
If logs still show violations against services you depend on, the policy hasn't converged. Don't enforce.
A counterintuitive detail: CSP doesn't cost performance
| Metric | Before CSP | After CSP |
|---|---|---|
| LCP | 1.7s | 1.8s |
| TBT | 240ms | 60ms |
| Performance score | 94 | 97 |
The score went up — but not because of CSP. In the same change I removed an inline script that patched <html lang>, replaced by a static route-group layout. The TBT gain came from that.
CSP's own cost lives inside the browser and is microseconds. Not measurable.
Two gotchas worth recording
1. Content-Security-Policy and Content-Security-Policy-Report-Only can coexist
Common during rollout: enforce a narrow policy while observing a wider one. But the browser evaluates both independently and reports merge. Either use one, or tag the source in your log line.
2. The report endpoint is exempt from your own CSP
report-uri requests are browser-generated and not subject to connect-src. With default-src 'self' and a same-origin endpoint this is fine; cross-origin report collectors need extra attention.
Bottom line
A CSP's value isn't how many directives it has. It's whether you know what you broke.
A CSP without reporting is indistinguishable from no CSP — right up until something stops working and you can't explain why.
Does your site have these problems?
Run the free audit tool for a 30-second report, or just write to me and describe the situation — I will tell you whether it is worth fixing.