Content Security Policy Explained for Developers
Photo by Unsplash on Unsplash
Table of Contents
What a CSP actually buys you
The mental model that made this click for me: your application already decides what code it *intends* to run. A CSP is you writing that intention down somewhere the browser can enforce it.
Without one, an attacker who finds a way to inject a `<script>` tag into your page gets the same privileges your own code has. Session cookies, DOM access, the ability to make authenticated requests as the user. Everything.
With a well built CSP, that injected script simply doesn't execute. The browser looks at it, checks it against the policy, and refuses. The XSS bug is still a bug, and you should still fix it, but it stopped being a catastrophe.
That's the trade: some real configuration pain in exchange for turning your worst class of vulnerability into a much less bad one.
Reading a policy, directive by directive
``` Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-r4nd0m'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https://api.example.com; frame-ancestors 'none'; base-uri 'self' ```
Reading it in order:
`default-src 'self'` is the fallback. Anything not given its own directive falls back to this, meaning only resources from your own origin are allowed. Starting here is the right instinct.
`script-src` is the one that matters most, because script execution is what turns an injection into a compromise. Here it permits scripts from your origin plus any inline script carrying the matching nonce.
`style-src` in this example allows inline styles, which is a compromise almost everyone makes because so many libraries inject style attributes. It's a much smaller risk than inline script, though not zero.
`img-src 'self' data: https:` allows your own images, data URIs, and any HTTPS image. Fairly permissive, and usually fine.
`connect-src` governs fetch, XHR and WebSocket destinations. Forgetting to list your API origin here is the single most common way people break their own app.
`frame-ancestors 'none'` stops your page being embedded in an iframe elsewhere, which is clickjacking protection. It supersedes the older X-Frame-Options header.
`base-uri 'self'` stops an injected `<base>` tag redirecting every relative URL on the page to an attacker's server. It's cheap and it closes a genuinely nasty hole.
MDN's CSP guide has the full directive list, which is longer than anyone needs on day one.
Why unsafe-inline throws the whole thing away
Plenty of tutorials, and plenty of real deployments, include `script-src 'self' 'unsafe-inline'`. It makes the console errors go away. It also makes the policy almost pointless.
The entire mechanism of XSS is getting the browser to execute script that appears inline in your HTML. `unsafe-inline` tells the browser inline script is fine. You've written a policy that permits the exact thing the policy exists to prevent.
There are two proper answers.
A nonce is a random value generated fresh on every single response. You put it in the header and on each legitimate inline script tag:
```html <script nonce="r4nd0m">/* your inline code */</script> ```
The browser runs scripts whose nonce matches and blocks everything else. An injected script can't guess the nonce because it changes every request. That last part is essential: a nonce reused across responses, or baked into a statically cached page, is worthless. If your HTML is cached at a CDN, this needs care.
A hash is the alternative for inline scripts that never change. You take the SHA-256 of the script contents and list it. No per request work needed, which suits static sites, but you have to regenerate the hash every time the script changes.
For third party scripts, `strict-dynamic` is worth knowing about. It says: trust scripts loaded by an already trusted script. That lets a tag manager or bundler load its own dependencies without you enumerating every domain, which is otherwise a maintenance nightmare.
Report-Only is how you actually ship this
Use the sibling header instead:
``` Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-report ```
This enforces nothing. The browser evaluates the policy, blocks nothing, and sends you a JSON report every time something *would* have been blocked. You get the complete inventory of what your site actually loads, including all the things you forgot about.
That inventory is always longer than expected. Analytics, a chat widget, a font CDN, an old marketing pixel nobody remembers adding, an inline onclick handler in a template from 2019.
I've run this on two sites now and both times the report volume in week one was roughly triple what I expected. Run it for a couple of weeks on real traffic. Real users hit paths your test suite doesn't. Then tighten based on evidence rather than guesswork, and only flip to the enforcing header once the reports go quiet.
Two warnings about reports. First, you'll get noise from browser extensions injecting scripts into your pages, which you cannot control and should filter out. Second, `report-uri` is deprecated in favour of `report-to`, though support is uneven enough that sending both is still common practice.
Once enforcing, keep the reporting endpoint. A sudden spike in violations is a genuinely useful security signal, and it's the closest thing you get to an early warning that someone is probing you.
The mistakes that break production
Forgetting `connect-src` for your API. The page loads, looks perfect, and every fetch fails. Because the failure is in the network layer rather than the render, it often gets misdiagnosed as a CORS problem. It isn't, and the CORS explainer covers how to tell the two apart, since the console messages are distinct once you know what to look for.
Nonces in cached HTML. Your page is cached at the edge, every visitor gets the same nonce, and the protection evaporates silently. Nothing breaks visibly, which is what makes it dangerous.
Missing `font-src` when your CSS pulls fonts from another origin. Text renders in a fallback and looks subtly wrong.
`frame-src` for embedded content. Videos and payment iframes stop loading, and the error is easy to miss because the iframe just sits there empty.
Inline event handlers. Every `onclick="..."` in your markup is inline script and gets blocked. There is no nonce for attribute handlers, so these have to be rewritten as event listeners. On an older codebase this is the bulk of the migration work.
Blocking your own error reporting. If Sentry or similar isn't in `connect-src`, you lose visibility exactly when you need it.
None of this is difficult, it's just tedious, and the tedium is why so many sites ship a policy that's technically present and practically useless. A CSP is one layer among several, not a substitute for the input handling covered in the SQL injection guide, and it sits alongside the other security headers in the HTTP headers rundown.
Frequently Asked Questions
What does Content Security Policy do?
It's a response header listing which sources the browser may load scripts, styles, images and other resources from. Its main value is limiting the damage of cross-site scripting, since an injected script that isn't allowed by the policy simply won't execute. It's a mitigation layer, not a replacement for fixing the underlying bug.
Why is unsafe-inline bad in a CSP?
Because inline script execution is exactly how XSS works, so allowing it undoes most of the protection. Use a per-request nonce on your legitimate inline scripts instead, or a SHA-256 hash for scripts that never change. Allowing unsafe-inline for styles is a much smaller compromise and is common in practice.
What is a CSP nonce?
A random value generated fresh for every response, included in the header and as a nonce attribute on each trusted inline script tag. The browser runs matching scripts and blocks the rest, and an attacker can't guess it because it changes each request. It must genuinely be per-request, so cached HTML breaks the guarantee.
How do I add a CSP without breaking my site?
Deploy Content-Security-Policy-Report-Only first with a reporting endpoint. It enforces nothing but tells you everything that would have been blocked, on real traffic, including the third party scripts you forgot about. Run it a couple of weeks, tighten from the reports, then switch to the enforcing header.
Why are my API calls failing after adding a CSP?
You almost certainly missed connect-src, which governs fetch, XHR and WebSocket destinations. Add your API origin to it. This gets misread as a CORS failure regularly, though the two are separate mechanisms with different console messages, as the [CORS guide](/blog/what-is-cors-error-how-to-fix-it) explains.
Does CSP replace other security headers?
Partly. The frame-ancestors directive supersedes X-Frame-Options for clickjacking protection, so you don't need both. It doesn't replace HSTS, referrer policy or the rest, and it certainly doesn't replace validating and escaping input on the server side.
Try ToolsFuel
23+ free online tools for developers, designers, and everyone. No signup required.
Browse All Tools