Why Allowlist-Based CSP Failed Against XSS
The original Content Security Policy spec (CSP Level 1 and 2) gave developers a straightforward promise: list the domains you trust, and the browser blocks everything else. In theory, this should stop cross-site scripting cold. In practice, it didn't.
Researchers found that allowlist-based CSP is fundamentally flawed as an XSS mitigation. The problem isn't the concept — it's the internet. Almost every allowlisted domain hosts at least one endpoint an attacker can abuse:
- JSONP endpoints — Services like Google APIs expose JSONP callbacks. If you allowlist
*.googleapis.com, an attacker can loadhttps://accounts.googleapis.com/o/oauth2/revoke?callback=alert(1)and execute arbitrary JavaScript. - Angular/library CDNs — Allowlisting a CDN that hosts AngularJS gives attackers a template injection primitive. They load Angular from the CDN and use
ng-appwith a template expression to bypass CSP entirely. - Open redirects — If any allowlisted domain has an open redirect, it can redirect to a
data:orblob:URL, bypassing the allowlist. - Script hosting services — CDNs like
cdnjs.cloudflare.comhost thousands of scripts. Allowlisting the domain means any of those scripts can be loaded, including old vulnerable versions.
A 2016 Google research paper analyzed over a billion CSP policies in the wild. The result: 94.72% of policies that attempted to mitigate XSS could be bypassed. Allowlists were security theater for most deployments.
This is why CSP Level 3 exists.
CSP Level 3: Nonce-Based Policies and strict-dynamic
CSP Level 3 shifts the trust model from "which domains do I trust?" to "which specific script instances do I trust?" The mechanism is nonces — random, single-use tokens generated on every page load.
Here's how a nonce-based policy works. Your server generates a random value per request and injects it into both the CSP header and your script tags:
Content-Security-Policy: script-src 'nonce-4AEemGb0xJptoIGFP3Nd' 'strict-dynamic'; object-src 'none'; base-uri 'self'
In your HTML:
<script nonce="4AEemGb0xJptoIGFP3Nd" src="/js/app.js"></script>
<script nonce="4AEemGb0xJptoIGFP3Nd">
// Inline scripts also work with the nonce
initApp();
</script>
An attacker who injects a script tag via XSS can't know the nonce — it changes on every request. Without the correct nonce, the browser blocks the script. Unlike allowlists, there's no domain to exploit and no endpoint to abuse.
What strict-dynamic Does
Real applications don't just load static script tags. They dynamically create scripts at runtime — loading modules, injecting analytics, lazy-loading components. Without strict-dynamic, you'd need to nonce every dynamically created script, which is often impossible.
strict-dynamic solves this by propagating trust: any script that runs with a valid nonce can create new scripts, and those child scripts are automatically trusted. This makes nonce-based CSP compatible with modern JavaScript patterns without weakening security.
When strict-dynamic is present, the browser ignores allowlist entries and unsafe-inline in script-src. This means you can include fallback values for older browsers that don't support CSP Level 3:
Content-Security-Policy:
script-src 'nonce-4AEemGb0xJptoIGFP3Nd' 'strict-dynamic' https: 'unsafe-inline';
object-src 'none';
base-uri 'self'
Modern browsers use the nonce and strict-dynamic, ignoring https: and unsafe-inline. Older browsers that don't understand strict-dynamic fall back to the allowlist. It's a graceful degradation strategy.
If you're currently using unsafe-inline, read our guide on how to fix unsafe-inline in CSP — migrating to nonces is the recommended path.
Trusted Types: Stopping DOM XSS at the API Level
Nonce-based CSP is strong against reflected and stored XSS, but DOM XSS is different. DOM XSS doesn't involve the server at all — the vulnerable code runs entirely in the browser, manipulating the DOM with unsanitized data.
Consider this pattern:
// DOM XSS — the server never sees this
element.innerHTML = location.hash.slice(1);
document.write(userInput);
el.insertAdjacentHTML('beforeend', data);
These dangerous sinks — innerHTML, document.write, eval, and others — accept raw strings and treat them as code or markup. CSP can't distinguish between legitimate use and exploitation because the script executing the sink has a valid nonce.
Trusted Types is a browser API that addresses this gap. It enforces that dangerous sinks only accept typed objects instead of raw strings. You enable it via CSP:
Content-Security-Policy:
require-trusted-types-for 'script';
trusted-types myPolicy
Now any attempt to assign a raw string to a dangerous sink throws a TypeError. You must create values through a policy:
const policy = trustedTypes.createPolicy('myPolicy', {
createHTML: (input) => DOMPurify.sanitize(input),
createScript: (input) => input, // only if you really need it
createScriptURL: (input) => {
const url = new URL(input, document.baseURI);
if (url.origin === location.origin) return url.href;
throw new TypeError('Untrusted URL: ' + input);
}
});
// This now works — goes through sanitization
element.innerHTML = policy.createHTML(userInput);
// This throws TypeError — raw string blocked
element.innerHTML = userInput;
Trusted Types forces all DOM XSS vectors through a centralized, auditable chokepoint. Instead of auditing every use of innerHTML across your codebase, you audit the policy functions. Google has deployed Trusted Types across many of their applications and reports it effectively eliminates DOM XSS.
Other Headers That Complement CSP Against XSS
CSP doesn't operate in isolation. Several other security headers reduce XSS attack surface:
X-Content-Type-Options: nosniff
Without this header, browsers may MIME-sniff responses and treat non-script files as JavaScript. An attacker could upload a file with script content disguised as an image, and the browser might execute it. nosniff forces the browser to respect the declared Content-Type, closing this vector.
Cross-Origin-Opener-Policy (COOP)
COOP prevents other windows from obtaining a reference to your window object. Set Cross-Origin-Opener-Policy: same-origin to isolate your browsing context. This stops cross-origin attacks that rely on window.opener to manipulate your page.
Permissions-Policy
Permissions-Policy (formerly Feature-Policy) restricts which browser APIs your page can use. While not directly an XSS mitigation, disabling unnecessary APIs reduces what an attacker can do if they get code execution:
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()
If injected script tries to access the camera or read geolocation, the browser blocks it regardless of CSP.
Subresource Integrity (SRI)
Even with strict-dynamic, you're trusting that third-party scripts haven't been tampered with. If an attacker compromises a CDN or supply-chains a popular library, your nonce-based CSP will happily execute the malicious version — the nonce validates, and strict-dynamic propagates trust to everything it loads.
Subresource Integrity (SRI) pins scripts to a specific cryptographic hash:
<script src="https://cdn.example.com/lib.js"
integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
crossorigin="anonymous"
nonce="4AEemGb0xJptoIGFP3Nd"></script>
The browser fetches the script, hashes it, and compares. If the hash doesn't match — because the file was modified, replaced, or intercepted — the browser refuses to execute it.
SRI and strict-dynamic Together
There's a nuance here. strict-dynamic propagates trust to scripts loaded by a trusted script, but SRI only applies to the scripts you explicitly tag with integrity. Dynamically loaded child scripts won't have SRI hashes unless the parent script adds them explicitly.
This means SRI protects your entry points — the scripts you load directly. For full supply-chain protection, you also need to audit what those scripts load at runtime and consider using import maps or module integrity proposals as they mature.
Sanitization as the First Line
CSP, Trusted Types, and SRI are all defense-in-depth measures. They assume your code has a bug and limit the damage. But the first line of defense is and always will be not having the bug in the first place.
Server-Side Sanitization
Always sanitize and escape user input on the server before it reaches the browser. Use context-aware output encoding:
- HTML context — Escape
<,>,&,",' - Attribute context — Quote all attributes and escape accordingly
- JavaScript context — Use JSON serialization, never string concatenation
- URL context — Validate the scheme (
http:/https:only) and encode
Most modern frameworks handle this by default (React's JSX, Django's templates, Go's html/template). The danger comes when you opt out — dangerouslySetInnerHTML, |safe, template.HTML(). Every opt-out should be audited.
Client-Side Sanitization
When you must render user-supplied HTML in the browser (rich text editors, markdown preview, embedded content), use a proven sanitizer:
// DOMPurify — the most widely used HTML sanitizer
import DOMPurify from 'dompurify';
element.innerHTML = DOMPurify.sanitize(untrustedHTML);
// With Trusted Types integration
const policy = trustedTypes.createPolicy('sanitize', {
createHTML: (input) => DOMPurify.sanitize(input)
});
Never write your own HTML sanitizer. The problem space is enormous — SVG, MathML, mutation XSS, encoding quirks — and even experienced security teams get it wrong. Use a battle-tested library and keep it updated.
Putting It All Together
Here's a recommended header set that combines everything discussed in this post. This gives you layered XSS protection:
# CSP with nonce and strict-dynamic
Content-Security-Policy:
script-src 'nonce-{RANDOM}' 'strict-dynamic' https: 'unsafe-inline';
style-src 'self' 'nonce-{RANDOM}';
object-src 'none';
base-uri 'self';
require-trusted-types-for 'script';
trusted-types myPolicy
# Prevent MIME sniffing
X-Content-Type-Options: nosniff
# Isolate browsing context
Cross-Origin-Opener-Policy: same-origin
# Restrict APIs
Permissions-Policy: camera=(), microphone=(), geolocation=()
# Additional hardening
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
This is not a one-size-fits-all configuration. Your specific policy will depend on your application's needs — what third-party scripts you load, whether you use iframes, and what browser APIs you require. The key principle is: start strict and relax only where necessary.
For a deeper understanding of each header and what it does, see our complete guide to Content Security Policy.
Test Your Headers
Configuring headers is only half the job. You need to verify they're actually deployed and working. Use HeaderTest's free scanner to check your site's security headers right now — it analyzes your CSP, detects common misconfigurations, and gives you actionable recommendations to improve your security posture.