Sacred Lantern Studio
2026-11-08· 7 minSecurityCSPNext.jsDevOps

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

  1. CSP violations are silent. Resources get blocked, the page renders fine, nothing surfaces outside the console.
  2. Report-Only needs a receiving endpoint, otherwise the browser discards reports and your policy is decoration.
  3. 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-report and 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:

  1. No violations pointing at origins you actually use.
  2. Remaining violations are limited to browser-extension injection (chrome-extension://) and data: 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.