Content Security Policy Explained
A practical tour of CSP: what each directive does, how nonces and hashes work, how to roll out a policy safely with report-only mode, and the mistakes that quietly weaken it.
What you'll learn
- ✓What CSP is and what it actually blocks
- ✓The directives that matter most
- ✓Nonces vs hashes vs allowlists
- ✓Rolling out CSP without breaking the site
- ✓Common ways policies become useless
Prerequisites
- •Basic web frontend and HTTP header knowledge
Cross-site scripting is still the easiest way to compromise a web app. Content Security Policy is the browser-enforced second line of defense: even if an attacker injects a script tag, the browser refuses to run it. The catch is that a sloppy policy gives you the warm feeling of security without the security.
What and Why
CSP is an HTTP response header that tells the browser which sources of content are allowed for a page. Scripts, styles, images, fonts, frames, connections, and more each get a directive. Anything not allowed is blocked and, optionally, reported. The policy is enforced by the browser, so it cannot be turned off by attacker-controlled markup.
The point is not to replace input sanitization or output encoding. It is to contain the blast radius when those fail. A strict CSP turns a stored XSS from “attacker runs JavaScript as your user” into “attacker injects HTML that the browser refuses to execute.”
Mental Model
Think of CSP as a guest list at the door. The page is the venue. Every resource that wants in shows the bouncer where it came from. Inline scripts have no origin, so they need a special pass, either a one-time nonce or a fingerprint hash. Anything without a pass and not on the list gets turned away.
The directives form a tree. default-src is the fallback for most fetch types; script-src, style-src, img-src, connect-src, frame-src, and a few others override it for their type. object-src 'none', base-uri 'self', and frame-ancestors 'none' are the cheap wins almost every site should set.
Hands-on Example
A reasonable strict policy for a server-rendered app:
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-r4nd0m1234';
style-src 'self' 'nonce-r4nd0m1234';
img-src 'self' data: https://cdn.example.com;
connect-src 'self' https://api.example.com;
frame-ancestors 'none';
base-uri 'self';
object-src 'none';
report-to csp-endpoint
The server generates a fresh nonce per response and injects it into legitimate tags:
<script nonce="r4nd0m1234">window.__INIT__ = { user: "ada" };</script>
An attacker who injects a <script>...</script> cannot guess the nonce, so the browser blocks it.
HTML parser sees <script>
|
v
inline? --no--> src origin in script-src list? -- yes --> run
| |
yes no
| v
has nonce/hash blocked + report
matching policy?
| \
yes no
| \
run blocked + report Start in report-only mode so violations get reported but nothing is blocked.
Content-Security-Policy-Report-Only:
default-src 'self'; script-src 'self' 'nonce-...'; report-to csp-endpoint
Collect reports for a week, fix the legitimate violations, then flip to the enforcing header.
Common Pitfalls
unsafe-inlineinscript-src. This single keyword neutralizes most of CSP’s XSS protection. If you cannot move to nonces yet, at least pair it with'strict-dynamic'and a nonce.- Wildcards on CDNs.
script-src https:or*.cloudfront.netlets attackers host scripts on the same wildcard. Pin specific hosts. - Allowing
data:inscript-src. That permitsdata:text/javascript,...payloads. Never do this. - Forgetting
base-uri. Without it, an injected<base>tag can redirect every relative URL to an attacker domain. - Not setting
frame-ancestors. Without it, anyone can iframe your app and try clickjacking.frame-ancestors 'none'or'self'shuts that down and replaces the oldX-Frame-Options.
Practical Tips
Use nonces over allowlists when you can; they survive code changes and CDN swaps. For fully static sites, hashes work well because content does not change. Add upgrade-insecure-requests to nudge mixed content to HTTPS. Send reports to an endpoint you actually watch; an unread report is just noise. Test the policy in your real preview environments because third-party scripts (analytics, support widgets, A/B tools) all have opinions, and you will discover them at the worst possible moment.
CSP works best alongside other headers: Strict-Transport-Security, Referrer-Policy, Permissions-Policy, and Cross-Origin-Opener-Policy. None of them replace each other.
Wrap-up
CSP turns “we hope our sanitizer is perfect” into “the browser will refuse to run anything we did not vouch for.” Get there with nonces, no inline escapes, a tight frame-ancestors, and a report-only rollout. The policy will look long, and that is fine; a long, specific allowlist beats a short, vague one every time.
Related articles
- Web Cookies vs localStorage vs sessionStorage
Compare the three main browser storage mechanisms by lifecycle, capacity, security, and use case so you pick the right tool for tokens, preferences, and state.
- Web CORS Explained In Depth
Understand Cross-Origin Resource Sharing: simple vs preflight requests, credentials, and the headers that decide whether your browser will let your fetch succeed.
- Web JWT vs Session Authentication
Compare JSON Web Tokens with classic server-side sessions across storage, revocation, scaling, and security so you can choose the right model for your app.
- Web Web Performance: Core Web Vitals Guide
A practical guide to Core Web Vitals: LCP, INP, and CLS. How they are measured, what hurts them, and concrete fixes that move the numbers.