Skip to main content
Security7 min readMarch 3, 2026

Content Security Policy: Stopping XSS at the Browser Level

A deep dive into Content Security Policy implementation — building a strict CSP for modern JavaScript applications, handling violations, and migrating legacy apps without breaking them.

James Ross Jr.

James Ross Jr.

Strategic Systems Architect & Enterprise Software Developer

Content Security Policy: Stopping XSS at the Browser Level

Content Security Policy is the closest thing we have to a silver bullet against XSS. It does not prevent XSS vulnerabilities from existing in your code — if you are injecting unsanitized HTML, you still have a vulnerability. But a properly configured CSP prevents that vulnerability from being exploited, because the browser refuses to execute the injected script.

The reason more applications do not implement CSP is that doing it correctly is not trivial. Inline scripts, CDN dependencies, analytics tools, chat widgets, and third-party embeds all require careful allowlisting. Getting the policy wrong breaks your application. This guide walks through the correct implementation approach.

How CSP Actually Works

CSP is a response header your server sends. The browser reads it and enforces the rules on every resource request made by the page. When a resource violates the policy — a script loaded from an unlisted domain, an inline script when 'unsafe-inline' is not permitted — the browser blocks it and (if configured) reports the violation to an endpoint.

The critical insight: an attacker who achieves XSS can inject a <script> tag. Without CSP, the browser happily executes it. With CSP specifying script-src 'self', the browser sees the injected script, checks the policy, determines that the script is not from an allowed source, and refuses to execute it. The XSS vulnerability exists but cannot be exploited.

This does not mean CSP replaces proper output encoding and input validation. Defense in depth — CSP is an additional layer, not a substitute for secure coding.

Nonces: The Right Way to Allow Inline Scripts

The biggest challenge with CSP is inline scripts. Many applications and third-party integrations use inline scripts. Adding 'unsafe-inline' to your script-src defeats much of CSP's value — it allows any inline script, including injected ones.

The solution is nonces. A nonce is a cryptographically random value generated per request and added to both the CSP header and any legitimate inline scripts on the page. The browser executes an inline script only if its nonce matches the policy.

import { randomBytes } from "crypto";

function generateNonce(): string {
  return randomBytes(16).toString("base64");
}

// Middleware that adds a nonce to each request
app.use((req, res, next) => {
  res.locals.cspNonce = generateNonce();

  res.setHeader(
    "Content-Security-Policy",
    [
      `default-src 'self'`,
      `script-src 'self' 'nonce-${res.locals.cspNonce}'`,
      `style-src 'self' 'unsafe-inline'`,
      `object-src 'none'`,
      `frame-ancestors 'none'`,
    ].join("; ")
  );

  next();
});

In your template:

<!-- The nonce attribute allows this specific script -->
<script nonce="<%= cspNonce %>">
  window.__INITIAL_STATE__ = <%= JSON.stringify(initialState) %>;
</script>

An attacker who injects <script>alert(1)</script> does not have the nonce, so the browser blocks it. Your legitimate inline script has the correct nonce and executes. This is the correct approach for applications that need inline scripts.

The nonce must be:

  • Cryptographically random (at least 128 bits)
  • Different for every response (not reused between requests)
  • Never derived from anything an attacker can predict or influence

Hash-Based CSP for Static Inline Scripts

If you have inline scripts that are static — the same content every time — you can use a hash instead of a nonce. Compute the SHA-256 hash of the script content and add it to the policy:

echo -n "console.log('hello');" | openssl dgst -sha256 -binary | base64
# Output: abc123def456... (your hash)
Content-Security-Policy: script-src 'self' 'sha256-abc123def456...'

The browser executes inline scripts whose content hashes match the allowlisted hashes. A modified script (which an attacker's injected content would be) has a different hash and is blocked.

This works for scripts that never change. For scripts that include dynamic content (like window.__INITIAL_STATE__ above), use nonces.

Handling Third-Party Dependencies

Analytics tools, chat widgets, A/B testing platforms, payment processors — they all require adding to your CSP. The correct approach:

  1. Check the vendor's documentation for their CSP requirements. Most major vendors document this.
  2. Add only the domains they actually load resources from, not wildcards.
  3. Test in a staging environment with the policy in report-only mode first.

For Google Analytics 4:

script-src 'self' https://www.googletagmanager.com https://www.google-analytics.com;
img-src 'self' https://www.google-analytics.com;
connect-src 'self' https://analytics.google.com https://www.google-analytics.com;

For Stripe.js:

script-src 'self' https://js.stripe.com;
frame-src https://js.stripe.com https://hooks.stripe.com;
connect-src 'self' https://api.stripe.com;

Avoid * wildcards in your CSP domains. https://*.example.com allows any subdomain of example.com — if any of those subdomains is compromised or user-controlled, it breaks your CSP protection.

The CSP Migration Strategy

Deploying CSP on an existing application without breaking it requires a phased approach.

Phase 1: Audit mode. Deploy with Content-Security-Policy-Report-Only pointing to a reporting endpoint. Do not block anything yet.

Content-Security-Policy-Report-Only: default-src 'self'; report-uri /api/csp-violations

Implement the reporting endpoint to log violations:

app.post("/api/csp-violations", express.json({ type: "application/csp-report" }), (req, res) => {
  const report = req.body["csp-report"];
  logger.warn({ cspViolation: report }, "CSP violation detected");
  res.status(204).end();
});

Phase 2: Build your policy from violations. Run in report-only mode for one to two weeks in production. Every violation is a resource your policy needs to allow. Review the violations, categorize them as legitimate (add to policy) or suspicious (investigate), and build your allowlist.

Phase 3: Progressive enforcement. Start with a permissive policy that does not break anything and tighten it progressively:

  1. Deploy with default-src 'self' 'unsafe-inline' 'unsafe-eval' https: — very permissive, probably does not break anything
  2. Remove https: and replace with specific domains as you identify what is needed
  3. Replace 'unsafe-inline' with nonces for scripts once you have identified all inline scripts
  4. Remove 'unsafe-eval' once you have confirmed nothing uses eval()

This migration can take weeks or months for complex applications. The reward is a policy that genuinely protects against XSS exploitation.

CSP in Next.js and Nuxt.js

Both frameworks require specific handling for their runtime JavaScript.

For Next.js, configure CSP in next.config.js with nonce support through middleware:

// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { randomBytes } from "crypto";

export function middleware(request: NextRequest) {
  const nonce = Buffer.from(randomBytes(16)).toString("base64");

  const csp = [
    `default-src 'self'`,
    `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
    `style-src 'self' 'unsafe-inline'`,
    `object-src 'none'`,
    `frame-ancestors 'none'`,
  ].join("; ");

  const response = NextResponse.next({
    request: { headers: new Headers(request.headers) },
  });

  response.headers.set("Content-Security-Policy", csp);
  response.headers.set("x-nonce", nonce);

  return response;
}

The 'strict-dynamic' directive is important for modern frameworks — it allows scripts loaded by a trusted (nonce-verified) script to run, even if they are not explicitly allowlisted. This handles the dynamic script loading that bundlers use.

Monitoring CSP Violations in Production

CSP violations in production fall into three categories: legitimate resources your policy does not cover (fix by updating policy), browser extensions injecting content (expected, do not block), and actual attack attempts.

Tools that aggregate CSP reports and help you distinguish signal from noise: Sentry (has built-in CSP violation reporting), Report URI (purpose-built for CSP reporting), and a simple custom endpoint feeding into your logging stack.

Set up an alert for CSP violations that match patterns suggesting actual attacks rather than browser extensions:

app.post("/api/csp-violations", (req, res) => {
  const report = req.body["csp-report"];

  // Filter likely browser extension injections
  const isBrowserExtension = report["source-file"]?.startsWith("chrome-extension:");
  const isMozExtension = report["source-file"]?.startsWith("moz-extension:");

  if (!isBrowserExtension && !isMozExtension) {
    logger.warn({ cspViolation: report }, "CSP violation - potential attack");
    // Alert if blocked-uri looks like XSS payload
  }

  res.status(204).end();
});

CSP violations from browser extensions are normal and expected — extensions inject scripts into pages routinely. Filter them out before alerting or your monitoring is noise.


If you need help implementing CSP for an existing application or want a review of your current policy, book a session at https://calendly.com/jamesrossjr.


Keep Reading