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, tells the browser which resources a page may load and which script execution patterns it may use.
You normally send the policy in the Content-Security-Policy HTTP response header.
A strong policy makes Cross-Site Scripting harder to exploit. If an attacker injects a script, the browser checks it against the policy before running it.
CSP is a second layer. You still need output escaping, safe DOM APIs, input handling, dependency updates, and authorization.
The free Web Application Security course puts CSP beside the other browser and server controls it depends on.
The mental model
A page loads many kinds of resources:
- scripts
- stylesheets
- images
- fonts
- frames
- API connections
- media
CSP gives each kind a source list.
resource request -> matching directive -> allowed or blocked
For example:
Content-Security-Policy: default-src 'self'; img-src 'self' https://images.example.net
Most resource types can load only from the page’s own origin. Images can also load from https://images.example.net.
The browser enforces the policy. Your server only sends it.
Start with a baseline policy
Here is a useful starting point for a site whose assets come from its own origin:
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'
Put it on one line in the actual header.
This policy says:
- 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
Do not paste it into production yet. First understand the directives and inventory what the site actually loads.
default-src is a fallback
default-src is the fallback for many fetch directives.
If img-src is absent, images use default-src. If img-src is present, it replaces that fallback for images.
Content-Security-Policy: default-src 'none'; img-src 'self'
This does not mean “block everything, then add same-origin images to it.” It means images use img-src 'self'; other covered resource types fall back to default-src 'none'.
Some directives do not fall back to default-src. frame-ancestors, base-uri, and form-action need their own values.
The directives you will use most
script-src
Controls JavaScript sources and important inline-script behavior:
Content-Security-Policy: script-src 'self' https://analytics.example.net
Host allowlists are easy to start with, but they can be weaker than they look. If an allowed host serves JSONP, user uploads, or a compromised script, that script inherits your trust.
Nonce- or hash-based strict policies provide a stronger script boundary.
style-src
Controls external stylesheets and inline styles:
Content-Security-Policy: style-src 'self'
Inline <style> blocks and style="" attributes are blocked unless the policy allows them through an appropriate nonce, hash, or inline policy.
A utility framework compiled into a normal same-origin CSS file works with 'self'. A library that injects <style> elements at runtime needs additional handling.
img-src
Controls images:
Content-Security-Policy: img-src 'self' data: https://cdn.example.net
Add data: only when the site uses data-URL images. Every allowed source expands the places an attacker might use for data exfiltration.
connect-src
Controls browser-initiated connections such as fetch, XMLHttpRequest, WebSocket, and EventSource:
Content-Security-Policy: connect-src 'self' https://api.example.net wss://socket.example.net
If an API call suddenly fails after CSP enforcement, inspect connect-src and the browser console.
frame-src
Controls which frames the page can load:
Content-Security-Policy: frame-src https://payments.example.net
This is different from frame-ancestors, which controls who may frame your page.
frame-ancestors
Block framing:
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> can change how relative script, form, and link URLs resolve. Strict CSP examples include base-uri for this reason.
form-action
Restrict form destinations:
Content-Security-Policy: form-action 'self' https://checkout.example.net
form-action does not inherit from default-src, so set it explicitly on pages with forms.
Why 'unsafe-inline' is weak
This policy allows every inline script:
Content-Security-Policy: script-src 'self' 'unsafe-inline'
That includes an injected <script> block and inline event handlers such as onclick.
The policy can still restrict external origins, but its XSS protection is much weaker.
Use external scripts, nonces, or hashes instead of adding 'unsafe-inline' as the first fix.
Use a nonce for dynamic HTML
A nonce is a random value created for one HTTP response.
Put it in the policy:
Content-Security-Policy: script-src 'nonce-4n0Tq8c6Yz2mQw'
Put the same value on scripts you trust in that response:
<script nonce="4n0Tq8c6Yz2mQw" src="/app.js"></script>
The browser runs scripts with the matching nonce.
The example value is short for readability. Generate a cryptographically random, unpredictable value for every response.
In Node.js:
import { randomBytes } from 'node:crypto'
const nonce = randomBytes(16).toString('base64')
Then build both the header and HTML from that server-side value.
Never reuse one nonce across responses. Never put a fixed nonce in a static configuration file. A predictable nonce stops being an authorization signal.
Nonce-based HTML also affects caching. If a CDN caches the page, the cached header and HTML must contain the same nonce. Generate them together at the layer that owns the response.
Use hashes for stable inline scripts
A static site cannot create a new nonce per response without dynamic middleware.
For a stable inline script, CSP can allow the hash of its exact content:
<script>
document.documentElement.classList.add('js')
</script>
Generate a SHA-256 hash of the script body and add it to script-src:
Content-Security-Policy: script-src 'self' 'sha256-BASE64_HASH_HERE'
Whitespace and punctuation are part of the hash. Changing the script requires a new header value.
Hashes work well for small static boot scripts. Moving the code into /app.js is often easier.
A strict script policy
CSP Level 3 defines a strict policy around nonces or hashes with 'strict-dynamic':
Content-Security-Policy: script-src 'nonce-RANDOM_VALUE' 'strict-dynamic'; base-uri 'self'; object-src 'none'
In supporting browsers, a trusted nonce-bearing script can load additional scripts. Host and scheme sources in the same directive are ignored for script loading under 'strict-dynamic'.
This works well for applications with a trusted loader or modern framework. It is not automatically the best policy for a small static site.
Choose one script trust model and test it. A long list containing 'self', many hosts, a nonce, 'unsafe-inline', and 'strict-dynamic' is hard to reason about across browser versions.
Inline event handlers need removal
This HTML is inline script:
<button onclick="save()">Save</button>
A strict policy blocks it.
Move the behavior into a trusted script:
<button id="save-button">Save</button>
document
.querySelector('#save-button')
.addEventListener('click', save)
Removing inline handlers improves both CSP and code organization.
Inventory the page before writing the policy
Open the browser network panel and list every resource origin.
Classify each one:
script -> script-src
stylesheet -> style-src
image -> img-src
font -> font-src
fetch/SSE -> connect-src
iframe -> frame-src
form -> form-action
Then ask whether each third party is needed.
CSP is a good reason to remove unused analytics, widgets, font hosts, and tag-manager containers. A shorter source list is easier to understand and harder to abuse.
Do not add https: or * to silence reports. That turns a useful allowlist into a broad network permission.
Roll out with report-only mode
Use Content-Security-Policy-Report-Only to observe violations without blocking them:
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
The browser sends reports to the named endpoint.
Report delivery is best effort. It is telemetry, not a complete security log.
Reports can contain page URLs, resource URLs, and other details. Protect the endpoint, limit body size, rate-limit it, and decide how long to retain the data.
The older report-uri directive is deprecated, but it can still be useful as a compatibility fallback while report-to support varies:
Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-report; report-to csp
Check current browser support for the audience you serve.
Report-only is not enforcement
A report-only policy does not block an attack.
Use it to discover breakage, fix the policy, then send an enforcing Content-Security-Policy header.
You can run both headers together during migration:
Content-Security-Policy: current enforced policy
Content-Security-Policy-Report-Only: stricter candidate policy
This lets you tighten one step at a time.
Test pages, not only the home page
Different routes load different resources.
Test:
- 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 uses SSE for LLM responses, the stream URL must be allowed by connect-src.
Open browser developer tools and look for CSP violations. Then run functional tests. A missing violation does not prove the user flow still works.
Send CSP as a header
CSP can also appear in a <meta http-equiv> element, but the header is the better default.
Some directives, including frame-ancestors, do not work from a meta-delivered policy. A header also applies before the browser parses document content.
Configure it at the application, reverse proxy, or hosting platform that serves the HTML response.
Do not attach an HTML CSP header to unrelated JSON, image, or font responses and assume it protects the page. The policy belongs on the document.
Multiple policies only get stricter
A response can contain multiple CSP policies. The browser enforces all of them.
They do not merge into one permissive union.
If one policy allows a script host and another blocks it, the load is blocked. This surprises teams that configure one policy in the app and another at the CDN.
Inspect the final production response headers:
curl -I https://example.com/
The deployed response is the source of truth.
Common mistakes
Treating CSP as an XSS fix
Fix the injection. CSP limits the damage when another defense fails.
Adding 'unsafe-inline' without a plan
Move handlers and scripts, or use nonces and hashes.
Reusing a nonce
A nonce must be unpredictable and unique per response.
Forgetting base-uri
An injected <base> can redirect relative URLs even when scripts are constrained.
Confusing frame-src and frame-ancestors
frame-src controls frames you load. frame-ancestors controls sites that frame you.
Allowing broad schemes
script-src https: trusts scripts from every HTTPS origin. HTTPS protects transport, not the script’s author.
Trusting report-only forever
It observes. It does not block.
Ignoring browser extensions
Extensions can create noisy reports that do not represent your deployed code. Group reports carefully before changing the policy.
Breaking the site and disabling CSP
Roll out gradually. A small enforced policy is better than an ambitious policy removed after one incident.
How I would deploy CSP
I would start by removing third-party scripts I do not need.
Then I would write a baseline policy from the actual network inventory and run it in report-only mode. I would test the important routes myself instead of waiting only for reports.
For a static site, I would prefer external same-origin scripts and hashes for the few inline blocks that remain. For a dynamic application, I would consider a per-response nonce.
I would enforce object-src, base-uri, frame-ancestors, and a realistic source policy first. Then I would tighten scripts and styles as the application allowed.
I would not weaken the whole site for one widget. I would remove the widget, isolate it, or give its exact sources the narrowest directives it needs.
CSP works best when it reflects a simple page. The policy is not only a header. It is a map of what your browser application trusts.
Want me to talk about your product? You can sponsor this site.
Related posts about network: