How to Implement a Content Security Policy Without Breaking Your Site
Content-Security-Policy is the single highest-leverage security header you can ship - and the one most teams avoid, because a wrong CSP doesn't fail loudly in a security scanner, it fails loudly in production by silently breaking your own scripts. Here's how to roll it out without that happening.
What CSP Actually Does
CSP tells the browser which sources are allowed to load scripts, styles, images, fonts, and other resources on your page. It's the primary defense against cross-site scripting (XSS) - even if an attacker manages to inject a <script> tag somewhere on your page, a correctly configured CSP stops the browser from executing it.
Why Teams Avoid It
A strict CSP breaks anything it wasn't written to allow: inline <script> tags, inline styleattributes, third-party analytics and chat widgets, embedded video players. Ship a strict policy without auditing first, and you'll find out what broke from users, not from a test suite. That fear is exactly why so many sites ship no CSP at all.
A Safe Rollout Strategy
The fix is a header most teams don't know exists: Content-Security-Policy-Report-Only. It reports violations to an endpoint you specify without blocking anything - you get a real list of everything your strict policy would have broken, with zero production risk.
1// next.config.js
2module.exports = {
3 async headers() {
4 return [
5 {
6 source: "/:path*",
7 headers: [
8 {
9 // Report-only: violations are logged, nothing is blocked yet.
10 // Switch to "Content-Security-Policy" once the report is clean.
11 key: "Content-Security-Policy-Report-Only",
12 value: "default-src 'self'; script-src 'self'; report-uri /api/csp-report",
13 },
14 ],
15 },
16 ];
17 },
18};- Ship the report-only header first, pointed at a logging endpoint
- Let it run for at least a few days of real traffic - not just your own testing, which won't exercise every code path
- Review the violation reports and decide, source by source, whether to allow it or remove it
- Once reports are clean, switch the header name to
Content-Security-Policy- same value, now actually enforced - Keep watching for new violations after every deploy that adds a new third-party script
Common Directives Explained
default-src- the fallback for any directive you don't set explicitly. Start with'self'.script-src- which sources can load JavaScript. The one that breaks the most things, and matters the most.style-src- which sources can load CSS, including inlinestyleattributes.img-src- image sources. Usually safe to leave permissive (data: https:) since images carry less risk than scripts.connect-src- wherefetch/XHR/WebSocket requests are allowed to go. Easy to forget and break your own API calls.frame-ancestors- who's allowed to embed your page in an iframe. Set to'self'or'none'for clickjacking protection.
Handling Third-Party Scripts and Inline Code
The reflex fix when CSP breaks an inline script is adding 'unsafe-inline' to script-src - which re-opens the exact vector CSP exists to close. A nonce is the correct fix: a random, per-request token that allows one specific inline script without opening the door to every inline script.
1// A nonce-based CSP allows one specific inline script per request,
2// instead of "unsafe-inline" allowing every inline script forever.
3const nonce = crypto.randomUUID();
4
5// Header
6`script-src 'self' 'nonce-${nonce}'`
7
8// The one inline script you actually need, tagged with the same nonce
9<script nonce={nonce}>{/* ... */}</script>For third-party widgets (analytics, chat, payment embeds), add their specific script origin to script-src rather than loosening the policy generally - and treat a vendor requiring 'unsafe-inline' or 'unsafe-eval' as a real reason to reconsider using them.
Find out which headers you're missing
Run a free security scan and get a plain-English breakdown of every header, cert, and exposed secret.
Run a free security scan