HTTP Headers

Content-Security-Policy: The Header That Stops Most XSS Attacks Cold

Rana Khan October 29, 2026 10 min read 3 views

XSS keeps working because browsers trust whatever the page tells them to load. Content-Security-Policy changes that contract: an allow-list the browser enforces even after your input filtering misses one.

Content-Security-Policy: The Header That Stops Most XSS Attacks Cold
πŸ“‘ Table of contents

Cross-site scripting has been breaking websites for a quarter century, and it keeps working for one structural reason: by default, the browser trusts whatever the page tells it. If an attacker can inject a script tag, the browser executes it. No questions asked.

Content-Security-Policy flips that contract. Instead of executing everything the document contains, the browser enforces an allow-list you define: these sources may load scripts, these may load styles, nothing else runs β€” no matter how it got into the page. It is the closest thing to an XSS vaccine, because it keeps protecting you after your input filtering misses one case. And almost every site that gets compromised through XSS either has no CSP or has one with a hole big enough to drive a payload through.

What CSP actually does

CSP is delivered as a single response header containing a set of directives. Each directive governs a resource type and lists the sources allowed to provide it:

Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example.net; object-src 'none'; base-uri 'self'; frame-ancestors 'self'

With that policy in place, a browser will refuse: a script tag pointing at evil.example (not in script-src), a Flash-like object embed (object-src 'none'), a base tag hijacking relative URLs (base-uri 'self'), and another site framing your page (frame-ancestors 'self').

The critical property is that this enforcement happens inside the browser, on every page load, against the final document β€” so it catches injections your server-side filters missed, DOM-based XSS that never touches your backend, and third-party widget compromises. Your filter has to be right every time; the policy only has to be strict once.

The directives that earn their keep

Directive Controls Sensible default
default-src Fallback for all fetch directives 'self'
script-src Where JavaScript may come from 'self' + nonce/strict-dynamic
style-src Where styles may come from 'self'
img-src Images 'self' data: https:
connect-src XHR, fetch, WebSocket targets 'self' + your API hosts
frame-ancestors Who may embed this page in a frame 'self' or 'none'
object-src Plugin objects (Flash-era, still worth killing) 'none'
base-uri The base tag (relative-URL hijacking) 'self'
form-action Where forms may submit 'self'

Start from default-src 'self' and open only what breaks. Directives you omit fall back to default-src, so a strict default with explicit exceptions is both safer and easier to audit than a patchwork.

Inline scripts: the problem and the two real solutions

Here is where most CSPs die. A huge share of real-world sites have inline scripts β€” analytics snippets, framework hydration code, that marketing tag someone pasted into the template in 2024. A strict script-src blocks them all, the site visibly breaks, someone adds unsafe-inline, and the policy becomes decorative: unsafe-inline allows every inline script, including the attacker's.

There are two legitimate ways out.

Nonces. On every response, generate a random value, put it in the policy (script-src 'nonce-r4nd0m...'), and stamp each of your inline scripts with the same value:

<script nonce="r4nd0m...">/* your code */</script>

The browser runs inline scripts only when the nonce matches. Injected script tags without the nonce are refused. The nonce must be fresh per response and cryptographically random β€” a hardcoded "nonce" is just unsafe-inline with extra steps.

Hashes and strict-dynamic. For script files you control, you can pin the hash of trusted code. For modern setups, strict-dynamic lets a trusted script (one with your nonce or hash) load its own dependencies transitively, so you do not have to enumerate every lazy-loaded chunk. Together they are the current best practice: 'strict-dynamic' plus a nonce, with host allow-lists demoted to legacy-browser fallback.

What you cannot do responsibly: keep unsafe-inline "temporarily." Policies with unsafe-inline provide near-zero XSS protection and still create compliance paperwork.

frame-ancestors: the modern clickjacking defense

Framing attacks (clickjacking, UI redressing) are covered by the X-Frame-Options header, but XFO predates CSP and only understands DENY and SAMEORIGIN. frame-ancestors supersedes it: it supports full source expressions, applies to all framing contexts, and is respected by every current browser. Use both β€” XFO for legacy clients, frame-ancestors as the real control β€” but know that frame-ancestors is the one doing the work. 2MNY's header audit treats a missing or misconfigured frame-ancestors as a finding in its own right, and its clickjacking module goes further: it actually attempts to frame your page in a headless browser and proves whether the protection holds.

Rolling out without breaking production

CSP has a deserved reputation for breaking sites when introduced carelessly. The fix is the staged rollout the spec designers built for exactly this purpose:

  1. Ship Content-Security-Policy-Report-Only first with the policy you want to have. Report-Only evaluates violations and reports them to your endpoint without enforcing anything.
  2. Collect and fix violations. Each report names the directive and the blocked resource. Add legitimate sources, add nonces to your inline code, remove what turns out to be dead third-party code.
  3. When reports go quiet, enforce. Flip to the real Content-Security-Policy header. Keep a report-uri (or report-to) in the enforced policy so new violations surface after changes.
  4. Re-test after every feature release. The classic CSP failure mode is a six-month-old strict policy quietly broken by one new marketing tag.

How to check where you stand

Read your headers: open developer tools, inspect the response for your homepage and one authenticated page (policies often differ, and the authenticated one matters more). Look for unsafe-inline in script-src, wildcards like https: (which allows every HTTPS host on earth), and the absence of frame-ancestors, object-src, and base-uri.

Then run a proper scan β€” the free tier at 2mny.com grades CSP as part of its header security audit: it parses the policy, flags the directives that undermine it, checks whether report-only mode has been left on indefinitely, and cross-references your third-party scripts for subresource integrity. For the underlying attack class, the XSS scanner explainer covers how detection actually works, and the cookie security guide completes the picture on the storage side of browser defenses.

CSP will not make XSS impossible. It makes XSS conditional β€” conditional on the attacker also compromising a source you allow, which is a much harder problem than pasting a payload into a comment box. In a world where the average site runs scripts from a dozen origins, that conditional is the difference between an incident and a log entry.

πŸ“’ SHARE 𝕏 in f
R
✍️ WRITTEN BY
Rana Khan
Founder & Security Lead, 2MNY ·

Security engineer building 2MNY β€” 292 free website-security checks β€” and the open-source Website Vulnerability Scanner. I find exposed secrets, misconfigurations and real vulnerabilities, then explain how to fix them in plain language.

Frequently Asked Questions
1 Does Content-Security-Policy stop all XSS attacks?

No β€” nothing does β€” but a correctly enforced CSP stops the most common classes. Reflected and DOM-based XSS typically need the attacker to run script from an unapproved source, which a script-src allow-list blocks. Attacks that still get through include script injection into an already-allowed source (a compromised CDN or a vulnerable first-party file) and markup injection that abuses HTML without executing script. CSP is defense-in-depth, not a replacement for output encoding.

2 How do I test my website's CSP?

Read the raw response headers in browser developer tools first, then use a free scanner. 2MNY's header security audit grades your CSP, flags unsafe-inline and overly broad wildcards, checks whether frame-ancestors is present for clickjacking protection, and cross-references your third-party scripts for subresource integrity β€” the full picture in one scan at 2mny.com.

3 What is a CSP nonce and why is it better than unsafe-inline?

A nonce is a fresh random value generated per response and attached to each legitimate inline script tag. The policy allows inline scripts only when their nonce matches, so attacker-injected script tags without the nonce are refused. Unlike unsafe-inline, which opens the door to every inline script including malicious ones, a nonce keeps the strictness while letting your own inline code run. It must change on every response and be cryptographically random to be secure.

4 Why is my Content-Security-Policy in Content-Security-Policy-Report-Only?

Report-Only mode is the rollout phase: the browser evaluates the policy and reports violations without enforcing anything. Sites use it to discover which resources would break before flipping to enforcement. If your policy has been in report-only for months, you are getting the reports without the protection β€” the header is a smoke detector with the battery removed.

Is your website affected?

Find out in minutes β€” free scan, professional report, no credit card.

Start Free Scan