Content Security Policy (CSP) explained
By Flavio Copes
Build and deploy a Content Security Policy with source directives, nonces or hashes, report-only rollout, framing protection, testing, and common CSP mistakes.
Content Security Policy, or CSP, is an HTTP header that tells the browser what a page is allowed to load. Scripts from here, images from there, no frames, no plugins. Anything outside the list is blocked.
You send it as the Content-Security-Policy response header.
The main reason to bother is Cross-Site Scripting. If an attacker manages to inject a script into your page, the browser checks it against the policy first. With a good policy, the injected script never runs.
CSP does not replace the fixes for XSS itself. You still escape output, use safe DOM APIs, validate input, update dependencies, and check authorization. CSP is what limits the damage when one of those fails.
My free Web Application Security course puts CSP next to the other browser and server controls it works with.
The mental model
A page loads a lot of different things:
- scripts
- stylesheets
- images
- fonts
- frames
- API connections
- media
CSP lets you give each kind its own list of allowed sources. When the browser is about to fetch something, it finds the directive for that kind of resource and checks the URL against it:
resource request -> matching directive -> allowed or blocked
Take this policy:
Content-Security-Policy: default-src 'self'; img-src 'self' https://images.example.net
Everything loads from the page’s own origin, except images, which can also come from https://images.example.net.
Your server only writes the header, and the browser enforces it.
Start with a baseline policy
Here is a starting point for a site that serves its own assets:
Content-Security-Policy: default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'
In the real header it is one line. Here is what each part means:
- load unspecified resource types only from this origin
- prevent
<base>from pointing outside this origin - block plugin content such as
<object> - prevent other sites from framing the page
- submit forms only to this origin
- load scripts, styles, fonts, and connections from this origin
- load images from this origin and
data:URLs
Don’t ship this yet. It’s a template, and your site loads things this template does not know about. The next sections explain the directives, then we look at how to find out what the page actually loads.
default-src is a fallback
default-src is what the browser uses when a more specific directive is missing.
No img-src? Images follow default-src. Add an img-src, and images stop looking at default-src at all.
Content-Security-Policy: default-src 'none'; img-src 'self'
It’s tempting to read this as “block everything, then also allow same-origin images”, but that’s wrong. Images follow img-src 'self', and every other resource type without its own directive follows default-src 'none'.
Some directives never fall back to default-src: frame-ancestors, base-uri, and form-action. If you want them, you have to write them.
The directives you will use most
script-src
Where JavaScript may come from, and how inline scripts are treated:
Content-Security-Policy: script-src 'self' https://analytics.example.net
A host allowlist like this is the easiest way to start, but it trusts everything those hosts serve. If analytics.example.net also hosts a JSONP endpoint, or user uploads, or gets compromised, any script it serves runs with your trust.
Nonces and hashes, which we’ll see below, give you a stricter boundary than a list of hosts.
style-src
Same idea for CSS:
Content-Security-Policy: style-src 'self'
With this policy, <style> blocks and style="" attributes are blocked. To allow them you need a nonce, a hash, or an explicit inline allowance.
A CSS framework compiled to a normal same-origin file works fine with 'self'. A library that injects <style> elements at runtime does not, and you’ll have to handle it.
img-src
Where images may come from:
Content-Security-Policy: img-src 'self' data: https://cdn.example.net
Only add data: if the site actually uses data-URL images. Every extra allowed source is another place an attacker can send data to.
connect-src
Covers fetch, XMLHttpRequest, WebSockets, and EventSource:
Content-Security-Policy: connect-src 'self' https://api.example.net wss://socket.example.net
When an API call starts failing right after you enable CSP, this is the directive to look at. The browser console will name the blocked URL.
frame-src
Which frames your page may embed:
Content-Security-Policy: frame-src https://payments.example.net
Don’t confuse it with frame-ancestors, which is about who may embed you.
frame-ancestors
To stop anyone from framing your page:
Content-Security-Policy: frame-ancestors 'none'
Or allow only your own origin:
Content-Security-Policy: frame-ancestors 'self'
This is the modern CSP control for clickjacking. I cover the related response headers in HTTP security headers.
base-uri
Restrict the HTML <base> element:
Content-Security-Policy: base-uri 'self'
An injected <base> tag changes where every relative URL on the page points, including script and form URLs. That’s why the strict CSP examples always include base-uri.
form-action
Where forms may be submitted:
Content-Security-Policy: form-action 'self' https://checkout.example.net
form-action does not inherit from default-src. On pages with forms, set it.
Why 'unsafe-inline' is weak
The first time a policy breaks a page, 'unsafe-inline' looks like the quick fix:
Content-Security-Policy: script-src 'self' 'unsafe-inline'
It allows every inline script. That includes the one the attacker injected, and every onclick attribute too.
External origins are still restricted, so the policy isn’t useless. But the part that mattered for XSS is mostly gone.
Move the code into external files, or use nonces or hashes. Only reach for 'unsafe-inline' when nothing else works.
Use a nonce for dynamic HTML
A nonce is a random value you generate for one HTTP response and use exactly once.
It goes in the header:
Content-Security-Policy: script-src 'nonce-4n0Tq8c6Yz2mQw'
And on every script you trust in that same response:
<script nonce="4n0Tq8c6Yz2mQw" src="/app.js"></script>
Scripts without the matching nonce don’t run. An injected script cannot guess a value that did not exist before this response.
The value in the example is short so it’s readable. In real code, generate a proper random one for every response. In Node.js:
import { randomBytes } from 'node:crypto'
const nonce = randomBytes(16).toString('base64')
Then build both the header and the HTML from that same value.
Reusing one nonce across responses silently breaks the guarantee, and so does putting a fixed nonce in a config file. In both cases the nonce becomes predictable, and a predictable nonce is no better than 'unsafe-inline'.
Caching is the other trap. If a CDN caches the HTML, the cached header and the cached page must carry the same nonce. Generate them together, at the layer that produces the response.
Use hashes for stable inline scripts
A static site has no request handler to generate a nonce per response. Hashes are the alternative.
Say you have this small inline script:
<script>
document.documentElement.classList.add('js')
</script>
Compute a SHA-256 hash of the script body and put it in script-src:
Content-Security-Policy: script-src 'self' 'sha256-BASE64_HASH_HERE'
The browser hashes the script content and compares. Whitespace and punctuation count, so any edit to the script means a new hash in the header.
Hashes work well for one or two small boot scripts. Past that, moving the code into /app.js is usually less work.
A strict script policy
CSP Level 3 has a name for the nonce-or-hash approach combined with 'strict-dynamic'. It calls it a strict policy:
Content-Security-Policy: script-src 'nonce-RANDOM_VALUE' 'strict-dynamic'; base-uri 'self'; object-src 'none'
In supporting browsers, 'strict-dynamic' means a script that carries a valid nonce is trusted to load more scripts. Any host or scheme source in the same directive is ignored for script loading, so the allowlist stops mattering.
This fits applications with a framework or a loader script. For a small static site it can be more than you need.
Pick one trust model for scripts and test it. A directive that combines 'self', a dozen hosts, a nonce, 'unsafe-inline', and 'strict-dynamic' behaves differently across browser versions, and nobody on the team can say what it allows.
Inline event handlers need removal
This is an inline script too:
<button onclick="save()">Save</button>
A strict policy blocks it.
Give the button an id and attach the handler from a trusted script:
<button id="save-button">Save</button>
document
.querySelector('#save-button')
.addEventListener('click', save)
You lose nothing, and the behavior now lives with the rest of your JavaScript.
Inventory the page before writing the policy
Before writing any policy, open the network panel and write down every origin the page loads from.
Sort them by directive:
script -> script-src
stylesheet -> style-src
image -> img-src
font -> font-src
fetch/SSE -> connect-src
iframe -> frame-src
form -> form-action
Then go through the third parties one by one and ask if you still need them.
This is a good moment to delete the analytics script nobody reads, the widget from a campaign two years ago, the font host you replaced, the tag manager. Every origin you remove is one less line in the policy and one less place for an attacker to use.
Whatever you do, don’t add https: or * to make the reports stop. The reports go quiet because the policy now allows almost anything.
Roll out with report-only mode
Content-Security-Policy-Report-Only lets you test a policy without blocking anything. The browser evaluates it, and instead of blocking, it sends you a report:
Reporting-Endpoints: csp="https://reports.example.net/csp"
Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; report-to csp
Reports go to the endpoint named in Reporting-Endpoints.
Delivery is best effort. Browsers batch them and don’t guarantee they arrive. Treat them as telemetry, not as a security log you can audit.
The reports contain page URLs, blocked resource URLs, and other details about your users’ sessions. Protect that endpoint, cap the body size, rate-limit it, and decide how long you keep the data.
The older report-uri directive is deprecated, but browser support for report-to is uneven, so you can send both during the transition:
Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-report; report-to csp
Check current support for the browsers your users actually use.
Report-only is not enforcement
A report-only policy tells you what would have been blocked. It blocks nothing. An attack against a page with only a report-only header succeeds, and you get a report about it.
Use it to find breakage, fix the policy, then switch to the enforcing Content-Security-Policy header.
The two headers can coexist, which is handy when tightening an existing policy:
Content-Security-Policy: current enforced policy
Content-Security-Policy-Report-Only: stricter candidate policy
You keep the current policy enforced while you observe what the stricter one would break.
Test pages, not only the home page
The home page is usually the easiest page to lock down. The others load things it doesn’t.
Go through:
- login and signup
- checkout
- pages with video or maps
- admin screens
- error pages
- pages with analytics consent states
- streaming and WebSocket features
If the app streams LLM responses over SSE, that stream URL has to be in connect-src.
Look at the console for CSP violations, then use the page. No violations in the console doesn’t mean the checkout still works.
Send CSP as a header
You can also put CSP in a <meta http-equiv> tag, but the header is better.
Some directives, frame-ancestors among them, are ignored in a meta tag. And the header is in place before the browser starts parsing the document, so nothing slips through in the meantime.
Set it wherever the HTML response is produced: the app, the reverse proxy, or the hosting platform.
The policy belongs on the HTML document. Adding it to JSON, image, or font responses does nothing for the page that loads them.
Multiple policies only get stricter
A response can carry more than one CSP header. The browser applies all of them.
A resource has to pass every policy, so a second header can only take sources away. If the app’s policy allows a script host and the CDN’s policy doesn’t, the script is blocked.
This one catches teams where the app sets a header and someone also configured one on the CDN. Check what actually reaches the browser:
curl -I https://example.com/
Whatever comes back from production is what the browser sees.
Common mistakes
Treating CSP as the XSS fix. Fix the injection itself, and keep CSP for the day another fix fails.
Adding 'unsafe-inline' because something broke. Move the handlers and scripts into files, or use nonces and hashes.
Reusing a nonce. It has to be random and different on every response, otherwise it protects nothing.
Leaving out base-uri. An injected <base> tag can redirect every relative URL on the page even when script sources are locked down.
Mixing up frame-src and frame-ancestors. frame-src controls what you embed, and frame-ancestors controls who embeds you.
Allowing whole schemes. script-src https: trusts every HTTPS origin on the internet. HTTPS only means the transport is encrypted, and tells you nothing about who wrote the script.
Staying in report-only forever. Report-only mode never blocks anything.
Reacting to extension noise. Browser extensions inject scripts and styles, and those show up in your reports. Group the reports before you change the policy because of them.
Disabling CSP after it breaks the site once. Roll out in steps. A small enforced policy protects more than an ambitious one you remove after the first incident.
How I would deploy CSP
I would start by deleting the third-party scripts I don’t need, because every script I remove is one less source to allow.
Then I would write a baseline policy from the real network inventory and send it in report-only mode. I would click through the important pages myself instead of waiting for the reports to tell me what broke.
On a static site I would keep scripts external and same-origin, and hash the one or two inline blocks that have to stay. On a dynamic app I would generate a nonce per response.
The first enforced version would cover object-src, base-uri, frame-ancestors, and a source list that matches what the site loads. Scripts and styles get tightened after, as the app allows.
I would not loosen the whole site for one widget. Either the widget goes, or it gets isolated, or it gets its exact sources in the narrowest directive that fits.
Want me to talk about your product? You can sponsor this site.
Related posts about network: