Let me be blunt: if you’re not running Content Security Policy on your website, you’re leaving the front door wide open for Cross-Site Scripting (XSS) attacks. And no, input sanitization isn’t enough. It never has been.
I’ve seen countless security audit reports where everything looks great on paper — input validation, output encoding, WAF rules — but one missing CSP header turns all of that into a suggestion rather than a guarantee.
What CSP Actually Does (Without the Jargon)
Think of CSP as a bouncer at a club. Your website tells the browser: “Here’s a list of exactly who’s allowed in. Everyone else? Turn them away.”
Specifically, CSP controls which scripts, styles, images, fonts, and other resources the browser is allowed to load. When an attacker injects a malicious <script> tag into your page, CSP looks at it and says, “Yeah, you’re not on the list” — and refuses to execute it.
That’s it. That’s the whole thing. It doesn’t try to detect bad scripts. It only allows known-good ones. It’s a whitelist approach, and it works incredibly well because it doesn’t matter how clever the attacker’s payload is. If it’s not on the list, it doesn’t run.
Why Input Sanitization Keeps Failing
Here’s the problem with the traditional approach: you’re trying to block bad input. But attackers keep finding new ways to sneak past filters. Remember when people thought stripping <script> tags was enough? Then came JavaScript event handlers like onerror, onload, onmouseover. Then came SVG-based attacks. Then came template injection. The list never ends.
CSP sidesteps this entirely. It doesn’t care what the input looks like. It cares where the script is being loaded from.
Even if the attacker finds a way to inject an inline script:
A Real CSP Policy (Not a Toy Example)
Most CSP tutorials show you something like default-src 'self' and call it a day. That’s fine for a demo, but let’s look at what a realistic policy for a modern web application looks like:
Let me break down what each line actually does and why it’s there:
default-src ‘self’ — This is your fallback. Any resource type not specifically covered by another directive falls back to this. Setting it to ‘self’ means “only load from our own domain by default.” This is your safety net.
script-src — This is where the magic happens. 'nonce-a1b2c3d4...' means every <script> tag must have a matching nonce attribute that changes on every request. Even if an attacker injects a script tag, they can’t guess the nonce. 'strict-dynamic' is a companion that says “trust scripts loaded by trusted scripts” — so your Google Analytics tag loaded via nonce can, in turn, load additional scripts.
style-src ‘unsafe-inline’ — Yeah, I know, unsafe-inline is supposed to be bad. But for styles, the risk is significantly lower than scripts. CSS can’t steal cookies or make API calls. It can mess up your layout (which is a CSS injection attack), but it’s not the same threat level. If you want to be strict, you can use nonces or hashes here too, but most teams start with unsafe-inline for styles.
img-src ‘self’ data: https: — Images from your own domain, data URIs (for inline SVGs and base64 images), and any HTTPS source. The https: might seem permissive, but images can’t execute code, so the risk is minimal.
connect-src — This controls where JavaScript can send requests (fetch, XMLHttpRequest, WebSocket). You want this locked down because this is how attackers exfiltrate data. Only allow your own API and any analytics endpoints you actually use.
frame-ancestors ’none’ — This prevents anyone from embedding your site in an iframe. It’s the modern version of X-Frame-Options: DENY. If you’ve ever seen clickjacking attacks where your login form gets loaded invisibly inside an attacker’s page, this stops it cold.
base-uri ‘self’ — Prevents attackers from changing the base URL of your page, which could redirect all relative URLs to their server.
form-action ‘self’ — Prevents forms from being submitted to external URLs. Stops a class of phishing attacks.
upgrade-insecure-requests — Automatically upgrades any HTTP request to HTTPS. Simple but effective.
The Two Headers You Need to Know
There are two CSP headers:
- Content-Security-Policy — Enforcement mode. The browser blocks violations.
- Content-Security-Policy-Report-Only — Reporting mode. The browser logs violations but doesn’t block anything.
Always start with report-only. Always. I don’t care how confident you are in your policy. Deploy it in report-only mode, collect violation reports for a week, fix the issues, then switch to enforcement.
The Migration Path Nobody Talks About
Here’s what actually works in practice:
Week 1: Deploy a strict policy in report-only mode. Yes, you’ll get hundreds of violations. That’s fine. You expected that.
Week 2: Categorize the violations. Most will fall into these buckets:
- Third-party scripts you forgot about (analytics, chat widgets, A/B testing tools)
- Inline scripts from your CMS or framework
- Lazy-loaded resources
Week 3: Fix the code. Move inline scripts to external files. Add nonces. Update your directive list. Deploy the updated policy in report-only mode again.
Week 4: If violations are near zero, switch to enforcement. Keep report-only running alongside it to catch anything new.
Ongoing: Keep the report-uri endpoint active. New violations will pop up whenever you add a new third-party integration or a developer adds an inline script.
What About Legacy Applications?
If you’re working with a WordPress site from 2017 or a jQuery-heavy app, CSP is harder. I’m not going to pretend it isn’t. Legacy apps use inline scripts everywhere, and refactoring them is a massive undertaking.
The pragmatic approach for legacy apps:
-
Start with
script-src 'unsafe-inline' 'self'— Yes, this is weak, but it’s better than nothing because you still getdefault-src,frame-ancestors, andconnect-srcprotections. -
Gradually extract inline scripts into external files. One at a time. No big bang rewrite.
-
Once all inline scripts are external, switch to nonce-based script-src.
-
Celebrate. You’ve just made XSS significantly harder.
Test Your Current Setup
If you’re reading this and thinking “I should probably check my headers,” you can use headertest.com to scan your site. It’ll tell you if you have a CSP header, what it looks like, and what’s missing.
No signup, no API key. Just paste your URL.
What’s Next?
Now that you understand what CSP is and why it matters, the next step is actually implementing it. I’ve written a step-by-step implementation guide that walks through the whole process — from your first report-only policy to a strict nonce-based setup.