Article
Content Security Policy explained in plain terms (with a policy you can actually use)
Published 2026-11-08
Content Security Policy explained in plain terms (with a policy you can actually use)
Content Security Policy, usually shortened to CSP, has a reputation for being complicated. The spec is long, the directive names look intimidating, and a wrong policy can break a live site in seconds. But the idea behind it is simple: your web server sends one HTTP header telling the browser what it is allowed to load and execute on the page. If something tries to run that the header did not permit, the browser refuses.
That is the whole concept. The rest is vocabulary.
The problem CSP is meant to solve
When a browser loads your page, it executes every script tag it finds. The browser has no way to tell the difference between a script you wrote and a script an attacker injected through a comment field or a vulnerable plugin. To the browser, it is all just JavaScript running in your site's origin, with full access to whatever your site can access: cookies, form data, the DOM.
CSP is your chance to tell the browser the rules ahead of time. If your policy says scripts may only load from your own domain, then a script pointing at evil.example.com gets blocked no matter how it got into your HTML. An injected inline script gets blocked too, if you did not allow inline scripts.
This matters because CSP keeps working after your sanitization fails. Escaping output and validating input are still the primary defense against cross-site scripting. But code review misses things, dependencies have bugs, and an editor plugin gets compromised. CSP is the layer that turns "we had an XSS hole" into "an XSS hole existed and the injected script did not run."
What a policy looks like
A CSP is a list of directives separated by semicolons. Each directive has a name and a value:
Content-Security-Policy: default-src 'self'; img-src 'self' https://cdn.example.net; frame-ancestors 'none'
Read it as three sentences. By default, load things only from this same origin ('self'). Images may also come from one specific CDN. And nobody is allowed to embed this page in a frame.
The directive you will use most is default-src. It is the fallback: any resource type that does not have its own directive inherits the default-src value. Then you carve out exceptions with specific directives as you need them:
script-src— where JavaScript may come from, and whether inline scripts are allowedstyle-src— stylesheets, same ideaimg-src— imagesfont-src— web fontsconnect-src— where XHR, fetch, and WebSocket may connect (analytics endpoints, APIs)frame-ancestors— who may embed this page in an iframe; the modern replacement for the oldX-Frame-Optionsheaderobject-src— plugins and object embeds; set it to'none'unless you genuinely need Flash-era embeds, which you do not
One detail that trips people up: a specific directive completely replaces the fallback for that type, it does not add to it. If default-src https: and you also set script-src 'self', then scripts are restricted to your origin only, not to "your origin plus anything HTTPS". Per the MDN documentation, each fetch directive fully overwrites the default for its resource type.
The keywords, and the two you should be suspicious of
You will see these quoted keywords in almost every policy:
'self'— the page's own origin'none'— block this resource type entirely'unsafe-inline'— allow inline<script>and<style>blocks and inline event handlers likeonclick'unsafe-eval'— alloweval()and similar text-to-code functions
The names are honest. unsafe-inline and unsafe-eval are named "unsafe" because they are unsafe. Allowing inline scripts means any injected inline script runs, which reopens the exact hole CSP is supposed to close. Allowing eval() means any string your code passes to eval() becomes executable, including strings an attacker influenced.
Plenty of sites run script-src ... 'unsafe-inline' because their template has hardcoded inline scripts and refactoring them is a project nobody wants to fund. That policy still restricts where external scripts load from, which is something. But be clear with yourself about what it is: a CSP with 'unsafe-inline' in script-src is not XSS protection. It is a partial restriction wearing a security badge.
Where the header goes
Send Content-Security-Policy as an HTTP response header on every response, not just the homepage. In nginx:
add_header Content-Security-Policy "default-src 'self'; frame-ancestors 'none'" always;
In Apache, inside your VirtualHost or .htaccess:
Header always set Content-Security-Policy "default-src 'self'; frame-ancestors 'none'"
If you cannot touch server config or headers, there is a fallback: a <meta http-equiv="Content-Security-Policy"> tag in your HTML. It works for the fetch directives, but it cannot use frame-ancestors, report-uri/report-to, or sandbox, and it only exists after the HTML has started parsing. Treat the meta tag as a last resort for static hosting, not the default approach.
One more thing you may run into in old tutorials: X-Content-Security-Policy and X-WebKit-CSP. Those are the pre-standardization headers from around 2013. Modern browsers all speak the unprefixed Content-Security-Policy header, and the OWASP cheat sheet is blunt about the old ones: ignore them.
Roll it out with Report-Only first
The mistake I see most often is a team writing a policy, shipping it to production, and then spending the afternoon reverting it because the checkout page went gray. There is a built-in mechanism for avoiding exactly that: the Content-Security-Policy-Report-Only header.
Same syntax, different header name. The browser does not enforce the policy. It just logs violations to the console and, if you add a report-to directive with a reporting endpoint, POSTs a JSON report for each violation. Your site keeps working while you find out what your policy would have broken.
The rollout that does not end in a rollback:
- Write the policy you want to end up with, not the one you already comply with.
- Ship it as
Content-Security-Policy-Report-Onlywith areport-toendpoint (even a dumb script that appends the JSON to a log file is fine for a small site). - Browse the whole site yourself: every form, the admin area, the pages with third-party embeds.
- Watch the console and the reports. Each violation tells you the page, the blocked resource, and which directive stopped it.
- Fix your code (move inline scripts into files, or use nonces) or adjust the policy until reports go quiet.
- Switch the header to
Content-Security-Policyand enforce.
For a small site this takes an hour. For a large one it is the difference between a quiet security improvement and an incident report.
Strict CSP: the version that actually stops XSS
Here is the uncomfortable part. The classic CSP style, listing trusted domains in script-src, is broadly bypassable. Google's own research on the topic found that most deployed allowlist policies could be circumvented, often because allowlists end up including a host that hosts a JSONP endpoint or an open redirect. If your allowlist contains a domain that will reflect arbitrary content in a script context, an attacker can use it.
The recommended alternative, what Google and the web.dev guides call a strict CSP, does not trust URLs at all. It trusts specific script tags:
Content-Security-Policy: script-src 'nonce-{RANDOM}' 'strict-dynamic'; object-src 'none'; base-uri 'none'
A nonce is a random string your server generates for every response. Each <script> tag that should run carries the same value in a nonce attribute. The browser executes only script tags whose nonce matches the header, plus scripts that those trusted scripts load themselves, which is what 'strict-dynamic' covers. Everything else, including anything an attacker injects, stays dead.
The nonce has to be different on every response. If you cache the HTML with a fixed nonce baked in, the whole scheme collapses, because a cached nonce eventually leaks and can be reused. For static sites or heavy HTML caching, there is a hash-based variant: 'sha256-{HASH}' of each inline script in place of the nonce. The tradeoff is maintenance: change a single character of inline JavaScript and you must recompute and update the header.
The https: and 'unsafe-inline' tokens you sometimes see tacked onto a strict policy are deliberate fallbacks for very old browsers, not holes. Modern browsers that understand 'strict-dynamic' ignore 'unsafe-inline' entirely, as documented in the web.dev strict CSP guide. I would still rather not have them in my header unless the traffic data says I need them.
Checking what you have today
You can inspect any site's current CSP from a terminal:
curl -sI https://example.com | grep -i content-security
Nothing printed means no policy at all, which is most of the web. To see a violation fire, open DevTools on your own site with a Report-Only policy set, and watch the console: blocked attempts appear with the directive that stopped them.
If your site is on WordPress, check whether a plugin is already sending a CSP header before adding your own. Multiple policies can coexist, but they only ever intersect: the browser enforces the strictest combination, so stacking a second policy cannot unblock something the first one forbids.
A starter policy
For a typical site that hosts its own assets and uses one analytics endpoint, this is a defensible starting point for Report-Only:
Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self' https://analytics.example.com; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self' https://analytics.example.com; frame-ancestors 'none'; object-src 'none'; base-uri 'self'; form-action 'self'
Then follow the report loop: browse, read reports, tighten or fix, enforce. If your templates use inline scripts, that is the moment to decide whether to extract them into files or move to a nonce-based strict policy. The second option is more work and is the one that actually stops an injected script.
CSP will not patch your vulnerabilities. It limits what an attacker can do with them, and it costs one header. There are not many security controls with a better effort-to-value ratio, once you skip the ones that only look like security.
Want to see what headers your site sends today? CheckWebSecurity assesses your website's external perimeter — security headers, TLS configuration, and exposure — and reports findings with plain-language explanations. Start a free check.
Related: How website security checks work · WordPress security scanning without plugins