Content Security Policy: A Practical Guide for SaaS Apps
A misconfigured CSP is as dangerous as having none at all. This guide walks through building a strict policy without breaking your third-party scripts or analytics.
By ShipReady · Updated
A Content Security Policy is your strongest in-browser defence against cross-site scripting: it tells the browser which sources of script, style and other content are allowed to run. Done well, it turns many XSS bugs into non-events. Done badly, it either blocks half your app or provides a false sense of security while allowing inline script — the exact thing XSS exploits.
What a CSP does and does not stop
Worth being precise, because a CSP is often sold as general-purpose armour and it is not. It constrains what the browser will load and execute on your page. That makes it a strong mitigation for cross-site scripting, clickjacking (via frame-ancestors), and data exfiltration to attacker-controlled hosts.
It does nothing about anything that happens on your server. An injection bug, a missing authorisation check, an exposed API key — a CSP is irrelevant to all of them. Treat it as a blast-radius limiter for one class of bug, not as a security posture.
Start in report-only
Never ship a strict CSP straight to production. Deploy it in report-only mode first, collect violation reports, and see what breaks before you enforce anything.
Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-reportCollect for a week under real traffic before enforcing. Report-only sends violations without blocking anything, so the cost of being wrong is a noisy log rather than a broken checkout. Note that browsers report from extensions and injected scripts too, so expect noise you did not cause — filter on your own origins rather than chasing every entry.
Prefer nonces over unsafe-inline
The single most common mistake is script-src 'unsafe-inline', which permits any inline script and defeats the point. Use a per-request nonce with strict-dynamic instead, so only scripts you explicitly mark can run.
Content-Security-Policy: script-src 'self' 'nonce-{random}' 'strict-dynamic'; object-src 'none'; base-uri 'self'Two properties make this work. The nonce is regenerated per request, so an attacker injecting markup cannot know it in advance — which means it has to come from middleware or a server component, never from a static build. And strict-dynamic says that a script you trusted with a nonce may load further scripts, which is what lets a tag manager or an analytics loader keep working without you allowlisting every host it reaches for.
One subtlety worth knowing: when a nonce or hash is present, browsers ignore unsafe-inline entirely. That is deliberate — it lets you keep unsafe-inline in the policy as a fallback for very old browsers without weakening modern ones. It also means a policy containing both is not necessarily broken, and a scanner that flags it without checking for a nonce is producing a false positive.
What usually breaks
- Inline event handlers and inline <script> blocks — move them to files or add a nonce.
- Third-party analytics and tag managers — allowlist their exact origins.
- Inline styles from CSS-in-JS — you may need a style nonce or hash.
- Fonts and images from CDNs — add font-src and img-src entries.
A rollout that does not break production
- Ship
Content-Security-Policy-Report-Onlywith the policy you want. Nothing is blocked. - Collect violations for a week, across real traffic and every route that matters — checkout and sign-up especially, since those carry the most third-party script.
- Fix your own violations: move inline handlers into files, add nonces to the inline scripts you genuinely need, allowlist the third-party origins you actually use.
- Switch the header name to
Content-Security-Policy. Keep the report-only header in place alongside it with a stricter policy if you want to keep tightening. - Verify with
curl -sI https://yourdomain.com | grep -i content-security. The header your server sends is the only one that counts.
Check the policy on more than your homepage. A framework that sets headers in middleware may skip static assets or specific route patterns, which is how one origin ends up serving two different policies depending on the path.
Do not stop at CSP
A CSP is one header among several that only work when they are actually served. HSTS closes the protocol-downgrade window, X-Content-Type-Options stops MIME sniffing, and Referrer-Policy stops your URLs leaking to third parties. Production-grade TLS is the layer underneath all of them.
ShipReady reads the policy your server actually returns — not the one in your config file — and reports a missing Content-Security-Policy as well as a policy that is present but permits unsafe-inline, unsafe-eval, or a wildcard script source. That distinction is the whole point: a header that exists and a header that protects you are different things.