Cross-site scripting — XSS — has been on the OWASP Top 10 since the OWASP Top 10 existed. The mechanic is simple: a string the user controls ends up unescaped in a page, the browser interprets it as code, and the attacker's JavaScript runs with the privileges of the site that served it. Session cookies, form data, anything the legitimate page can touch.
Decades of input sanitisation, output encoding, and framework auto-escaping have made XSS rarer than it used to be, but not rare. Every meaningful application has dozens of injection sinks — innerHTML assignments, document.write calls, dangerously-set React props, server templates with the wrong escape filter — and getting all of them right, forever, across every contributor and every dependency, is not realistic.
Content Security Policy takes a different angle. Instead of trying to stop the bad string from reaching the page, it tells the browser: here is the list of sources you're allowed to load and execute code from. Anything else, refuse. The XSS payload still gets injected — but when the browser tries to run it, the policy fires, the script is blocked, and the attack fails. CSP doesn't prevent the bug. It prevents the bug from doing anything.
How CSP works — the directive model
CSP is delivered as a response header (or, less commonly, a <meta http-equiv> tag). A real policy looks something like this:
Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https://api.example.com; frame-ancestors 'none'
It's a list of semicolon-separated directives. Each directive names a class of resource (scripts, styles, images, network connections, frames, fonts, media) and gives a source list of where that class is allowed to come from.
The common directives:
script-src— JavaScript files, inline scripts, event handlers,eval.style-src— Stylesheets and inline styles.img-src— Images.connect-src— XHR,fetch(), WebSocket, EventSource targets.font-src— Web font files.frame-src— Sources of<iframe>content.media-src—<audio>and<video>sources.default-src— Fallback for anything not explicitly listed.
And the source values that appear in those lists:
'self'— The page's own origin (scheme + host + port).'none'— Nothing. Blocks everything for this directive.- A specific origin —
https://cdn.example.com, or a wildcard likehttps://*.example.com. data:/blob:— URL schemes (often needed for inline images or generated content).'unsafe-inline'— Allow inline scripts/styles. Defeats most of the protection.'unsafe-eval'— Alloweval(),Function(), and similar dynamic-code paths.
default-src is the safety net. If you set default-src 'self' and then only override the directives you need (script-src, img-src), everything else — fonts, media, frames, connections — falls back to "self only" by default. Forgetting default-src entirely is the most common rookie mistake; without it, any directive you didn't write has no restriction at all.
Nonces and hashes — doing inline scripts safely
The simplest way to allow your own inline <script> tags is script-src 'self' 'unsafe-inline'. The problem is that this also allows every inline script — including the one the attacker just injected via XSS. 'unsafe-inline' doesn't distinguish between scripts you wrote and scripts an attacker wrote, because once they're in the page they look identical to the browser. Using it negates almost the entire point of CSP.
The modern alternative is per-request nonces. On every request, the server generates a random value (a "number used once"), puts it in the CSP header, and stamps it onto every legitimate inline script tag:
Content-Security-Policy: script-src 'self' 'nonce-r4nd0mV4lue=='
<script nonce="r4nd0mV4lue==">
// your real inline script
</script>
The browser executes only inline scripts whose nonce attribute matches the value in the header. An attacker can inject a <script> tag all they want, but they can't guess the nonce — it's different on every request — and without the matching nonce the browser refuses to run it.
The alternative to nonces is hashes. Compute the SHA-256 (or 384, or 512) of the inline script's contents, base64-encode it, and list it in the policy: script-src 'sha256-aBcDeF...'. The browser hashes every inline script it encounters and runs only the ones whose hash matches. Hashes work well for static inline scripts that never change; nonces are easier when content is dynamic.
Even with nonces, a long-tail problem remains: third-party scripts loaded by your trusted code (analytics tags that load other tags, ad slots that load creatives) won't have the nonce. The fix is 'strict-dynamic'. With this keyword, any script that carries a valid nonce or hash is allowed to load further scripts, regardless of the source list. The hostname allowlist becomes irrelevant; trust flows from the nonce. A modern script-src often looks like:
script-src 'nonce-r4nd0mV4lue==' 'strict-dynamic' 'unsafe-inline' https:
The 'unsafe-inline' and https: tokens are fallbacks for older browsers that don't understand 'strict-dynamic'; modern browsers ignore them in its presence. This pattern is what the Google security team recommends and what our CSP analyzer grades policies against.
Report-only mode and reporting
Enabling a strict CSP on a site that wasn't designed for one will break things — guaranteed. The first run typically blocks an analytics snippet, a chat widget, a font loaded from the wrong CDN, a stylesheet that lives on a marketing subdomain. You won't catch all of them in testing.
The escape hatch is report-only mode. Sending the policy as Content-Security-Policy-Report-Only instead of Content-Security-Policy tells the browser: evaluate the policy, log every violation, but don't block anything. Users see a working site; you see a stream of violation reports.
Reports are posted as JSON via the older report-uri directive or the newer report-to mechanism (which uses a separate Reporting-Endpoints header). A typical report contains the document URL, the blocked resource, the directive that was violated, and (if available) a line number — enough to diagnose what your policy is missing.
The standard rollout sequence: deploy report-only, watch reports for a week or two, fix every legitimate violation (either tighten the offending code or add the source to the allowlist), and only then flip the header name from -Report-Only to enforcing. Keeping report-only running in parallel with an enforcing policy is also useful — it lets you stage tightening changes without breaking production.
Common CSP mistakes
Most broken CSP deployments fail the same way. A quick tour of the recurring sins:
'unsafe-inline' as a global allow. The fastest way to "make CSP work" is to put 'unsafe-inline' in script-src and style-src. Sites that do this technically have CSP enabled — and get almost no XSS protection from it. Inline scripts injected by XSS look exactly like inline scripts you wrote.
Forgetting third-party domains. Analytics, advertising, error tracking, payment widgets, embedded video, chat support, A/B testing, customer-data platforms — every one of them loads from its own domain (often several) and many load further scripts from yet more domains. The site goes silently quiet until you add each one to the right source list. Ad networks are particularly painful because the ultimate creative URLs change constantly and aren't documented.
Wildcards that defeat the policy. script-src https: means "any HTTPS source anywhere." It's permissive enough that any attacker who can host their payload on a Let's Encrypt domain bypasses you completely. Use specific hostnames; reach for wildcards only when scoped (https://*.example.com).
No default-src. Without a fallback, every directive you didn't write is unrestricted. default-src 'self' as the first directive in your policy is the single most valuable line in a CSP.
Retrofitting onto an old codebase. Inline event handlers (onclick="..."), inline <script> blocks scattered through server-rendered HTML, jQuery patterns that build markup with $(html) — all of this is incompatible with a strict CSP. Cleaning it up takes weeks of work per medium-sized application and is the real reason CSP adoption is slower than it should be. New projects can adopt strict CSP from day one cheaply; legacy projects almost never can.
Other things CSP can do
CSP started as an XSS mitigation, but the directive set has grown to cover other browser-side security problems.
frame-ancestors controls who can embed your page in an <iframe> — the modern replacement for X-Frame-Options, with the bonus that it accepts a real source list (multiple origins, wildcards) instead of the all-or-nothing of the older header. frame-ancestors 'none' is the right default for any page that shouldn't be framed (login forms, banking dashboards), and is the standard defence against clickjacking.
upgrade-insecure-requests tells the browser to silently rewrite every http:// subresource URL on the page to https://. Useful while migrating an old site to HTTPS, when you know your servers are ready but the markup still has stray plaintext links. Pairs naturally with HSTS preloading for full HTTPS enforcement.
base-uri 'self' stops an injected <base> tag from redirecting every relative URL on the page to an attacker's domain — a subtle XSS escalation that bypasses many output-encoding defences.
form-action restricts where forms can submit, blocking an attacker who injects a form pointing at their own server.
CSP works alongside the rest of the security-header set we cover in HTTP security headers explained — HSTS for transport security, X-Content-Type-Options: nosniff for MIME confusion, Referrer-Policy for outgoing data leakage. The bigger picture lives at the Web Security hub, which collects the tools and headers in one place; you can inspect what any site is sending today with the HTTP header inspector.
CSP is the most powerful defence-in-depth tool in the browser. It also takes more upfront work than any other header on this list, because it forces you to actually inventory what your site loads — a question most teams have never sat down to answer. The work is worth it. A strict CSP turns the entire class of "attacker got XSS" into "attacker got XSS and the browser refused to run it."
Analyze any site's CSP header
Our tool fetches the live Content-Security-Policy header, parses every directive, grades the policy for known weaknesses ('unsafe-inline', missing default-src, overly broad wildcards), and shows you the safer alternatives where they exist.