Learn · Threat model
Preventing XSS with a Content Security Policy
Cross-site scripting has been on the OWASP Top 10 for two decades and it isn't going anywhere. A strict CSP doesn't fix the bugs that let it happen — but it dramatically raises the cost of exploiting them.
Why XSS is still expensive
When attacker-controlled JavaScript runs inside your origin, it inherits the user's session: it can read cookies that aren't HttpOnly, read the DOM, make authenticated requests, scrape tokens out of localStorage, and modify the page in ways the user can't distinguish from the real thing.
That's why XSS gets categorized as a high-severity finding even when the entry point looks small. A single reflected sink in a search box can be the entry point for credential theft, account takeover, or fraudulent transactions.
Background: OWASP — Cross-site scripting
The three flavors of XSS
The exact mitigation depends on where the unsafe value enters the page. CSP helps with all three, but in different ways.
Reflected XSS
Attacker-supplied script is reflected back into a server response — typically through a URL parameter or form field that gets echoed without escaping. The victim clicks a crafted link and the script runs in the browser as if it came from your origin.
Stored XSS
Malicious script is persisted in a database or other backing store — a profile bio, a comment, a chat message — and served back to other users. The blast radius is everyone who views the affected content.
DOM-based XSS
Sink-and-source flaws inside client JavaScript: a value from window.location, document.referrer, or postMessage flows into innerHTML, eval, or document.write. The server never sees it; the bug lives entirely in the browser.
How a strict CSP helps
A strict policy uses nonces or hashes for inline scripts, drops 'unsafe-inline' entirely, and adds 'strict-dynamic' so trusted scripts can load further trusted scripts without maintaining brittle host allowlists.
Block unknown script origins
A script-src directive that omits 'unsafe-inline' and lists only the origins you trust prevents attacker-injected <script src="https://evil.example"> tags from executing — even when the injection itself succeeds.
Disable inline execution by default
Without a nonce or hash, the browser refuses inline <script> blocks and inline event handlers. This single change neutralizes the most common reflected-XSS payload pattern.
Stop exfiltration paths
Even when an attacker manages to execute code, a tight connect-src plus form-action restricts where data can be POSTed. The script can run but it cannot phone home.
A strict CSP, in practice
Here's the shape of a nonce-based strict policy as recommended by web.dev's strict CSP guide. The nonce changes per response and is injected into every inline <script> tag your application emits.
Content-Security-Policy:
script-src 'nonce-{RANDOM}' 'strict-dynamic'
https: 'unsafe-inline';
object-src 'none';
base-uri 'none';
report-to csp-endpoint;Modern browsers honor the nonce and 'strict-dynamic'; older browsers fall back to the https: and 'unsafe-inline' tokens (which are ignored when the nonce is present in compliant browsers). This dual-mode shape gives you protection in modern browsers without breaking legacy ones.
What CSP doesn't fix
CSP is a layer in your defense — not a replacement for the layers underneath. A few things to keep in mind:
CSP does not patch the underlying injection bug. Output encoding, parameterized queries, and safe DOM APIs remain the primary defense.
DOM-based XSS sinks like innerHTML still execute attacker-controlled HTML inside your trusted origin — Trusted Types is the complementary control there.
Misconfigured policies (with 'unsafe-inline' or wide-open allowlists) provide little protection. Strict CSP — nonces or hashes plus 'strict-dynamic' — is the goal.
CSP enforcement varies between browsers. Treat it as defense in depth, not a sole control.