What is it? #
A Content Security Policy tells the browser which sources a page may load scripts, styles, images and other resources from.
Its main purpose is limiting the damage of cross-site scripting. If an attacker injects a script tag, a policy that only allows scripts from your own domain stops it executing.
It is defence in depth, not a replacement for output escaping. The escaping prevents the injection; the policy limits what an injection can do if escaping fails somewhere.
Deploying it is the hard part, because a strict policy breaks inline scripts, inline styles and third-party widgets. Report-only mode exists for exactly that reason.
Think of it like this #
A list of approved suppliers at a loading bay. Deliveries from anyone not on the list are refused.
If someone forges an order, the goods still cannot arrive, because the supplier is unknown. The list does not stop forgery — it limits what forgery achieves.
Simple example #
A comment field fails to escape one field. Without a policy, an injected script runs and steals session cookies. With one, the browser refuses to execute it and reports the attempt.
Code #
# Step 1: report-only. Nothing is blocked; violations are reported.
Content-Security-Policy-Report-Only:
default-src 'self';
script-src 'self';
style-src 'self';
img-src 'self' data: https:;
connect-src 'self' https://api.example.com;
report-uri /csp-report;
# Run this for a week. Every violation report is either something to
# fix or something to add to the policy.
# Step 2: enforce, once the reports are clean
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-{RANDOM}';
style-src 'self';
img-src 'self' data: https://cdn.example.com;
font-src 'self';
connect-src 'self' https://api.example.com;
frame-ancestors 'none'; # cannot be embedded in an iframe
base-uri 'self'; # stops base tag injection
form-action 'self'; # forms cannot post elsewhere
object-src 'none'; # no plugins
upgrade-insecure-requests;
report-uri /csp-report;
# Nonces: allow specific inline scripts without allowing all of them
import secrets
@app.middleware("http")
async def csp(request, call_next):
nonce = secrets.token_urlsafe(16) # a NEW value for every response
request.state.csp_nonce = nonce
response = await call_next(request)
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
f"script-src 'self' 'nonce-{nonce}'; "
"style-src 'self'; object-src 'none'; frame-ancestors 'none'; "
"base-uri 'self'; report-uri /csp-report"
)
return response
# In the template:
# <script nonce="{{ csp_nonce }}">initApp();</script>
Directives that defeat the purpose
'unsafe-inline' in script-src
allows every inline script, including injected ones. This removes
almost all of the XSS protection the policy provides.
'unsafe-eval'
allows eval and similar. Some older libraries require it; prefer
replacing the library.
script-src * or default-src *
allows any source, which is equivalent to having no policy.
Use nonces or hashes instead of 'unsafe-inline'.
Other headers worth setting alongside
X-Content-Type-Options: nosniff do not guess content types
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: geolocation=(), camera=(), microphone=()
X-Frame-Options: DENY legacy equivalent of frame-ancestors
How it works #
Each directive names the allowed sources for one type of resource. default-src is the fallback for anything not specified explicitly.
Report-only mode sends violation reports without blocking anything, which is the only safe way to introduce a policy to an existing site. The reports show exactly what would break.
A nonce is a random value generated per response, included in the header and on each permitted inline script tag. The browser executes only scripts carrying the matching value, so an injected script — which cannot know the nonce — is refused.
The nonce must be regenerated for every response. A fixed value is trivially copied by an attacker and provides nothing.
frame-ancestors 'none' prevents your pages being embedded in an iframe, which is the modern protection against clickjacking.
base-uri and form-action close less obvious holes: an injected base tag can redirect every relative URL, and an injected form can post credentials elsewhere.
'unsafe-inline' is the directive that quietly removes the benefit. It is the easy way to make a policy stop breaking things, and it allows precisely the injected scripts the policy was meant to stop.
Reports arrive as JSON POSTs to the report endpoint. Collecting them in production reveals both genuine attacks and browser extensions producing noise.
Real-world use #
Deploying CSP to an existing site is a project. Third-party analytics, chat widgets, payment iframes and inline event handlers all need accounting for.
The report-only phase typically runs for weeks on a large site, and the reports are the main source of work: each violation is either a legitimate source to add or code to change.
Sites built with CSP in mind from the start have a much easier time, because inline scripts and styles were avoided from the beginning.
Browser extensions generate substantial report noise, which is why reports need filtering rather than treating every one as an attack.
Even a partial policy has value. frame-ancestors, object-src 'none' and base-uri 'self' are easy to adopt and close real attack paths without touching page content.
Common mistakes #
- Enforcing a policy without a report-only period, breaking the site.
- Adding
unsafe-inlineto make it work, which removes most of the protection. - Reusing the same nonce across responses, making it useless.
- Treating CSP as a replacement for output escaping.
- Ignoring violation reports, so the policy never improves.
Practice #
Add a report-only policy to a site and collect violations for a few days. Categorise each as a legitimate source or something to fix. Then deploy an enforcing policy with nonces for inline scripts, and confirm an injected script tag is refused.