~/blog/-blog-content-security-policy-
blog · Security

Content Security Policy: Stopping XSS at the Browser Level

How Content-Security-Policy headers work, how to build a policy that blocks XSS without breaking your site, and how to use report-only mode to roll out CSP safely.

last updated · June 14, 2026by @vultio

What XSS is and why CSP exists

Cross-Site Scripting (XSS) is an attack where malicious JavaScript is injected into a page and executed in users' browsers with the same permissions as your legitimate code. An attacker who can run JavaScript in your page can steal session cookies, capture keystrokes, redirect users, make API calls as the victim, and exfiltrate any data the page can access.

Content Security Policy (CSP) is an HTTP response header that tells the browser which sources of scripts, styles, images, and other resources are allowed to load on a page. Even if an attacker successfully injects a script tag or event handler, the browser will refuse to execute it if it violates the policy. CSP is a defence-in-depth measure — it does not replace input sanitisation and output encoding, but it drastically limits the damage if those measures are bypassed.

The basic header syntax

Content-Security-Policy: directive1 source1 source2; directive2 source3

# A minimal policy for a simple web app
Content-Security-Policy:
  default-src 'self';
  script-src 'self';
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;
  font-src 'self' https://fonts.gstatic.com;
  connect-src 'self' https://api.example.com;
  frame-ancestors 'none'

Each directive controls a specific type of resource. default-src is the fallback for any directive not explicitly specified. 'self' means the same origin as the page. Directives are separated by semicolons; multiple sources within a directive are space-separated.

Key directives explained

default-src    — fallback for any unspecified directive
script-src     — JavaScript sources (most important for XSS defense)
style-src      — CSS sources
img-src        — image sources
font-src       — web font sources
connect-src    — fetch, XHR, WebSocket destinations
frame-src      — allowed iframe sources
frame-ancestors — who can embed this page in an iframe (replaces X-Frame-Options)
object-src     — plugins (Flash etc) — always set to 'none'
base-uri       — restricts <base> tag (prevents base-tag injection)
form-action    — where forms can submit
upgrade-insecure-requests  — auto-upgrade http:// to https://

The unsafe directives: when and why to avoid them

'unsafe-inline' allows inline scripts (<script>alert(1)</script>) and inline event handlers (onclick="..."). This completely defeats CSP's XSS protection — an attacker who can inject any HTML tag can run arbitrary code. Avoid'unsafe-inline' for script-src entirely.

'unsafe-eval' allows eval(), new Function(), and similar dynamic code execution. Many bundlers and some libraries (notably older versions of Angular) require it, but it is a significant security risk. Avoid it where possible; if required, ensure all data passed to eval() originates from your own code, never from user input.

'unsafe-inline' for style-src is less dangerous than for scripts, but still allows CSS injection attacks (which can be used for data exfiltration via CSS selectors). Use it only if migrating existing inline styles is impractical.

Nonces and hashes: allowing specific inline scripts safely

If you genuinely need inline scripts (for example, a small analytics snippet in the document head), use nonces or hashes instead of 'unsafe-inline'. A nonce is a random token generated per request; the same nonce value appears in the CSP header and in the script tag's nonce attribute. Only scripts with the matching nonce are executed.

// Server generates a fresh nonce for each request
import crypto from 'crypto';
const nonce = crypto.randomBytes(16).toString('base64');

// CSP header includes the nonce
res.setHeader('Content-Security-Policy',
  `script-src 'self' 'nonce-${nonce}'`
);

// HTML uses the same nonce on the inline script
// <script nonce="GENERATED_NONCE">
//   // This inline script is allowed because nonce matches
//   window._config = { apiUrl: 'https://api.example.com' };
// </script>

// For static inline scripts, use a hash instead
// Compute: echo -n "alert(1)" | openssl dgst -sha256 -binary | base64
Content-Security-Policy: script-src 'sha256-qznLcsROx4GACP2dm0UCKCzCG+HiZ1guq6ZZDob/Tng='

// The browser computes the hash of each inline script and compares
// Only scripts whose hash matches the policy are executed

Report-Only mode: deploy CSP without breaking anything

The biggest practical obstacle to CSP adoption is that an incorrectly configured policy breaks legitimate functionality: blocked scripts, missing styles, rejected API calls. The Content-Security-Policy-Report-Only header solves this — it applies the policy in monitoring mode, reporting violations without blocking anything.

# Deploy in report-only mode first
Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self'; report-uri /csp-report

# The browser sends violation reports to your endpoint as JSON
# POST /csp-report
{
  "csp-report": {
    "document-uri": "https://app.example.com/dashboard",
    "violated-directive": "script-src",
    "blocked-uri": "https://cdn.analytics.io/tracker.js",
    "source-file": "https://app.example.com/dashboard",
    "line-number": 23
  }
}

# Collect reports for a week in production
# Fix policy to allow legitimate resources
# Switch to enforcing mode when reports are clean
Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.analytics.io

A production-ready policy for a modern SPA

# Modern React/Vue/Angular SPA with external API and Google Fonts
Content-Security-Policy:
  default-src 'self';
  script-src 'self';
  style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
  font-src 'self' https://fonts.gstatic.com;
  img-src 'self' data: https:;
  connect-src 'self' https://api.example.com wss://api.example.com;
  frame-ancestors 'none';
  base-uri 'self';
  form-action 'self';
  object-src 'none';
  upgrade-insecure-requests;
  report-uri /csp-violations

# If the SPA uses Google Analytics or a CDN for scripts:
script-src 'self' https://www.googletagmanager.com https://www.google-analytics.com;

# Notes:
# - 'unsafe-inline' in style-src is acceptable (CSS injection less critical)
# - object-src 'none' blocks Flash, Java, etc. — always do this
# - frame-ancestors 'none' prevents clickjacking
# - upgrade-insecure-requests converts http:// links to https://

Start with Content-Security-Policy-Report-Only, collect violations for a week, refine the policy until violations represent only real attacks rather than legitimate resources, then switch to the enforcing Content-Security-Policyheader. Review the policy whenever you add new third-party scripts, CDN resources, or external API connections to your application.