Content Security Policy (CSP):A Practical Guide to Stopping XSS
Output encoding is the primary defense against cross-site scripting, but no large application encodes every output perfectly forever. Content Security Policy is the safety net for the encoding point you missed. It moves the decision of what code may run out of your templates and into an explicit policy the browser enforces on every page. This guide explains how CSP works, the directives that matter, how to build a policy that actually stops XSS, and the mistakes that quietly make a policy useless. For the attack it defends against, start with What Is Cross-Site Scripting (XSS)?
What Is a Content Security Policy?
A Content Security Policy is a set of rules, delivered in the Content-Security-Policy HTTP response header, that controls which resources a page is allowed to load and execute. Instead of trusting any script the page happens to contain, the browser checks each resource against your policy and blocks anything that does not match. The policy is a list of directives, each naming a resource type and the sources permitted for it.
Content-Security-Policy: default-src 'self';
script-src 'self' https://cdn.example.com;
style-src 'self';
img-src 'self' data:;
object-src 'none';
base-uri 'self';
frame-ancestors 'none'
This policy says: load everything from the site's own origin by default, allow scripts only from the origin and one named CDN, block all plugins (object-src 'none'), and forbid the page from being framed (frame-ancestors 'none'). An injected <script> pointing at an attacker's domain would not match script-src, so the browser refuses to run it.
How Does CSP Stop XSS?
Cross-site scripting works by getting attacker-controlled JavaScript to execute in the victim's browser. That script usually arrives in one of two forms: an inline <script> block injected into the HTML, or a <script src> that loads code from an attacker's server. A well-built CSP blocks both.
- Blocking inline scripts: by default, a CSP with a
script-srcdirective refuses to execute any inline script or event handler unless it carries a matching nonce or hash. An injected<script>alert(1)</script>has no valid nonce, so it never runs. - Restricting script sources:
script-src 'self'means the browser will only load scripts from your own origin. An injected<script src="https://evil.tld/x.js">is blocked because the attacker's domain is not on the allowlist. - Disabling dangerous evaluation: without
'unsafe-eval', calls toeval()and similar string-to-code APIs are blocked, closing a common DOM-based XSS sink.
The result is a strong second line of defense: to succeed, an attacker now needs both an injection point and a way to satisfy your policy. That is a far higher bar than injection alone. For how those injection points arise in the first place, see Types of XSS: Stored, Reflected and DOM-Based.
What Are the Most Important CSP Directives?
| Directive | What It Controls | Recommended Value |
|---|---|---|
| default-src | Fallback for resource types you do not set explicitly | 'self' |
| script-src | Where JavaScript may load and execute from | 'self' plus a nonce or hash; avoid 'unsafe-inline' |
| style-src | Where CSS may load from | 'self' (or a nonce for inline styles) |
| img-src | Where images may load from | 'self' data: as needed |
| object-src | Plugins such as Flash and Java applets | 'none' |
| base-uri | What the page's | 'self' |
| frame-ancestors | Which sites may embed this page in a frame | 'none' or your own origin |
| connect-src | Where fetch, XHR, and WebSocket may connect | 'self' plus trusted APIs |
object-src 'none' and base-uri 'self' are cheap wins that close injection tricks many policies forget. frame-ancestors 'none' also gives you clickjacking protection, replacing the older X-Frame-Options header.
How Do You Build a Strict CSP With Nonces?
The weakest common policies allow 'unsafe-inline', which permits any inline script and effectively disables CSP's XSS protection. The modern, strong approach is a nonce-based policy: the server generates a fresh random value on every response, adds it to the CSP header, and puts the same value on each legitimate inline script. Injected scripts cannot guess the nonce, so they are blocked.
// Server: fresh nonce per request
const nonce = crypto.randomBytes(16).toString('base64');
res.setHeader('Content-Security-Policy',
`script-src 'nonce-${nonce}' 'strict-dynamic'; object-src 'none'; base-uri 'self'`);
// Template: only scripts with the matching nonce run
// <script nonce="${nonce}">...trusted code...</script>
Adding 'strict-dynamic' lets a trusted, nonced script load the additional scripts it needs without you allowlisting every CDN by hand, which keeps the policy short and maintainable. This is the pattern OWASP recommends for new applications.
What Is CSP Report-Only Mode?
Rolling out a strict CSP on a live site risks breaking legitimate scripts. The Content-Security-Policy-Report-Only header solves this: the browser enforces nothing but reports every violation it would have blocked to an endpoint you specify. You deploy the policy in report-only mode, watch the reports, fix the legitimate resources that would break, and only switch to the enforcing header once the violation stream is clean.
Content-Security-Policy-Report-Only: default-src 'self';
script-src 'self'; report-uri /csp-violation-report
What Are the Most Common CSP Mistakes?
- Allowing 'unsafe-inline' in script-src. This single value re-permits injected inline scripts and is the most common reason a CSP provides no real XSS protection. Use nonces or hashes instead.
- Allowing 'unsafe-eval'. It keeps eval() and Function() working, which are DOM-based XSS sinks. Remove it unless a dependency genuinely requires it, then isolate that dependency.
- Overly broad allowlists. Whitelisting a large CDN or a wildcard host can let an attacker host a script on that same trusted origin. Prefer nonces with 'strict-dynamic' over long host allowlists.
- Forgetting object-src and base-uri. Leaving these unset can allow plugin-based or base-tag injection that bypasses your script-src.
- Setting CSP once and never reviewing it. New features and third-party scripts change what the page loads. Keep report-only monitoring on so regressions surface.
Is CSP a Replacement for Output Encoding?
No. CSP is a mitigation, not a fix. Output encoding prevents the injection from being interpreted as HTML or script in the first place; CSP reduces the damage when an injection slips through. OWASP is explicit that CSP should be treated as defense in depth layered on top of encoding, never as a substitute for it. A page that encodes correctly and ships a strict CSP forces an attacker to defeat two independent controls. For the full set of controls, see the OWASP XSS prevention checklist, and pair CSP with secure cookies as covered in Session Management and Secure Cookies.