Roll Out a Content Security Policy Without Breaking the Site
04 May 2026Ship 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.