IAnswerable

Roll Out a Content Security Policy Without Breaking the Site

04 May 2026

Ship a strict CSP on a live site without staging it first and you will break something — a payment widget, a font, an embedded video — and find out from an angry user, not from a test. There’s a staged path that avoids this entirely.

flowchart TD
    A["Stage 1: Report-only,\nwide open"] --> B["Stage 2: Run for a\nreal traffic cycle"]
    B --> C["Stage 3: Build allowlist\nfrom what you saw"]
    C --> D["Stage 4: Switch to\nenforcing, still monitored"]
    D --> E["Stage 5: Tighten what\nyou deferred"]
  

Stage 1: Report-only, wide open

Start by just watching what your site actually loads, without blocking anything:

Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-report

Every violation gets logged to the browser console and, if you set report-uri, POSTed to your endpoint as JSON. Nothing breaks yet — this is pure observation.

app.post('/csp-report', express.json({ type: 'application/csp-report' }), (req, res) => {
  console.log('CSP violation:', req.body['csp-report'])
  res.sendStatus(204)
})

Note: report-uri is the older, more widely supported directive. The newer report-to requires a separate Report-To header and JSON endpoint config — for a small site, stick with report-uri, it still works in every current browser.

Stage 2: Run it for a real traffic cycle

A week minimum, ideally through a billing cycle if you have monthly cron jobs or emails that render pages. Let it catch things that only happen occasionally — a monthly report generator that loads a charting library, an admin page nobody’s visited since launch.

Collect the distinct violated directives and blocked URIs:

grep 'csp-report' /var/log/app.log | jq -r '.["csp-report"]["blocked-uri"]' | sort -u

Stage 3: Build the allowlist from what you actually saw

Common entries you’ll find and what they mean:

Blocked URI Likely cause Fix
inline <script> tag in your HTML, or an onclick= attribute Move to external .js file, or add a nonce
fonts.gstatic.com Google Fonts Add to font-src
js.stripe.com Stripe.js Add to script-src and frame-src
eval A library using eval or new Function() Add 'unsafe-eval' only if you can’t replace the library

For inline scripts you can’t move to external files, use a nonce instead of 'unsafe-inline', which defeats the point of CSP entirely:

<script nonce="{{cspNonce}}">/* ... */</script>
Content-Security-Policy-Report-Only: script-src 'self' 'nonce-{{cspNonce}}'

Generate a fresh random nonce per request server-side — reusing one nonce across all requests is the same as not having one.

Stage 4: Switch to enforcing, still monitored

Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{{cspNonce}}'; style-src 'self' 'unsafe-inline'; font-src 'self' fonts.gstatic.com; frame-src js.stripe.com; report-uri /csp-report

Keep the report endpoint live even after enforcing. It’s now your early warning for anything new — a marketing team adding a widget, a new third-party script someone pastes into the CMS.

Stage 5: Tighten what you deferred

'unsafe-inline' on style-src is a common permanent compromise for small teams — inline styles are everywhere in most codebases and migrating them all isn’t worth it for the marginal security gain. Don’t feel obligated to reach a theoretically perfect policy; reach one that blocks script injection, which is the actual threat CSP defends against.

The mistake that undoes all of this

Deploying the enforcing header without ever running report-only first, because “it’s probably fine.” It usually isn’t. The one library you forgot about is the one that breaks in production, in front of users, at the worst time — report-only mode costs you a week and finds it for free.

The concrete next action

Add the Content-Security-Policy-Report-Only header with a default-src 'self' policy today. You lose nothing by watching first.