Cyber security · Browser Security

CSP Explained: Prevent XSS Without Breaking Your Site

A real CSP rollout plan with common pitfalls.

Reading time: ~8–12 min
Level: All levels
Updated:

Content Security Policy (CSP) is one of the few browser defenses that can stop a successful XSS from turning into data theft. But it also has a reputation for breaking sites, especially modern apps with bundlers, analytics, CDNs, and inline scripts. This guide explains CSP in plain language, then walks you through a rollout plan that works in real projects: start in report-only, fix violations, lock down scripts with nonces, and tighten over time without shipping outages.


Quickstart

If you want a CSP that improves security today without a week of firefighting, follow this sequence. Each step is designed to reduce risk while keeping the blast radius small.

1) Turn on CSP in Report-Only

  • Add Content-Security-Policy-Report-Only first (no blocking yet)
  • Set a report-to group (or report-uri as legacy) so you actually receive violation reports
  • Start with a minimal policy: default-src 'self' + allow your known CDNs
  • Watch for violations in logs/telemetry during real traffic

2) Fix the top 3 violators

  • Inline scripts/styles (migrate to bundled files or add nonces)
  • Unexpected third-party domains (ads, analytics, widgets)
  • Mixed content / wrong schemes (http vs https, ws vs wss)
  • Framework dev tooling that injects eval/inline (only allow in dev)

3) Lock down scripts with nonces

  • Generate a per-request nonce server-side
  • Send it in script-src 'nonce-...'
  • Add nonce attributes to any required inline scripts (ideally: none)
  • Avoid 'unsafe-inline' and 'unsafe-eval' in production

4) Switch to enforcing mode

  • Ship the same policy as Content-Security-Policy (blocking)
  • Keep Report-Only for a week to catch new breakage
  • Add a CSP regression check in CI (scan HTML for inline scripts)
  • Document “how to add a third-party script” safely
Fast win for most sites

If you can’t do a full CSP today, you can still get value by implementing Report-Only + a strict script-src plan. The majority of “site broke” issues come from scripts; styles and images are usually easy to allow safely.

Overview

A Content Security Policy tells the browser where your page is allowed to load code and content from. It’s an allowlist for scripts, styles, images, fonts, frames, and network requests. When you get XSS, the attacker’s goal is usually to run JavaScript that steals tokens, reads DOM data, or sends requests as the user. CSP can prevent those scripts from executing — even if the injection happens — by blocking sources that aren’t explicitly allowed.

What you’ll learn

Topic Why it matters Outcome
How CSP works Stops “load attacker script from random domain” Clear mental model for directives
Nonces vs hashes Eliminates 'unsafe-inline' without breaking Safe way to handle inline scripts
Rollout plan Prevents production outages Report-only → fix → enforce
Common pitfalls Most CSP failures are predictable Fast troubleshooting playbook

CSP isn’t a substitute for fixing XSS (you still must validate/escape and avoid dangerous sinks). Think of it as a powerful “seatbelt” that reduces impact when something slips through.

CSP is not “set and forget”

CSP is a contract between your frontend build, third-party scripts, and server headers. If you add new analytics, change CDNs, or introduce inline code, CSP can break — which is why a rollout plan and regression checks matter.

Core concepts

To roll out a CSP without breaking your site, you need two things: (1) a mental model of what the browser is enforcing, and (2) a safe migration away from “anything goes” behaviors like inline scripts, dynamic code evaluation, and uncontrolled third-party embeds.

What CSP actually does

CSP is a response header (or a <meta http-equiv> tag, less ideal) that the browser reads before executing the page. Each directive is an allowlist. If a resource violates the policy, the browser blocks it (enforcing mode) or reports it (report-only). The most important directive for XSS is script-src.

Key directives you’ll use

  • default-src: fallback allowlist for most resource types
  • script-src: where scripts can load from (most important)
  • style-src: where styles can load from
  • img-src, font-src, connect-src: images, fonts, network requests (XHR/fetch/websocket)
  • frame-ancestors: who can embed your site (clickjacking defense)

Special keywords

  • 'self': your own origin
  • 'none': block everything for that directive
  • 'unsafe-inline': allows inline scripts/styles (avoid in prod)
  • 'unsafe-eval': allows eval/new Function (avoid in prod)
  • 'nonce-...' / 'sha256-...': allows specific inline scripts safely

Nonces vs hashes (and when to use which)

CSP blocks inline scripts by default, which is a good thing: many XSS payloads rely on inline execution. When you must run inline scripts (e.g., a tiny bootstrap snippet), you have two safe options: nonces or hashes.

Option How it works Best for Tradeoffs
Nonce Server generates a random per-request token; inline script includes nonce attr Modern apps, SSR, templates with small inline needs Requires server/template integration; must be unique per response
Hash Policy includes hash of the exact inline script content Static pages with stable inline scripts Any change to script requires updating hash; brittle for dynamic content
Why CSP helps against XSS

Many XSS attacks work by injecting a <script src="https://attacker.site/payload.js"> tag or inline JS. A strict script-src blocks both: external scripts must be from approved origins, and inline scripts must present a nonce/hash. The injection might still occur in the DOM, but it can’t execute.

Report-only vs enforcing mode

Report-only is your safety net: it tells you what would break without actually blocking. You should always start there unless you have a very simple static site with no third-party scripts. When you flip to enforcing mode, you keep a parallel report-only policy for a while to catch regressions.

“CSP is failing” often means connect-src is missing

Modern apps call many endpoints: APIs, telemetry, feature flags, auth, websockets. If your site loads but network requests fail, check connect-src. It controls fetch, XHR, EventSource, and WebSocket connections.

Step-by-step

This section is a rollout plan you can apply to most stacks (Node, Python, Rails, Go, .NET). The key is to treat CSP as a migration: you’re moving from implicit trust (any script can run) to explicit trust (only approved scripts run).

Step 1 — Capture your current resource graph

Before writing a strict policy, learn what your pages actually load. In Chrome/Edge DevTools, open Network and filter by “JS”, “CSS”, and “Fetch/XHR”. You’re looking for domains: your own origin, CDNs, analytics, ads, identity providers, widget embeds.

Mini checklist

  • List script hosts (CDNs, tag managers, widgets)
  • List API hosts used by frontend (connect-src)
  • List font/image hosts (fonts are often blocked first)
  • Note any inline scripts/styles and why they exist

Step 2 — Add a Report-Only policy (safe baseline)

Start with a policy that is strict enough to be meaningful, but permissive enough that you can roll it out without breaking. A common baseline is default-src 'self' plus explicit allowances for known third-party domains. Don’t add 'unsafe-inline' in script-src unless you’re using report-only only as a discovery phase.

The following example is a production-friendly starting point for many sites. Replace the example domains with your real ones. Keep it in Report-Only first.

# Example: set CSP in report-only mode (reverse proxy / nginx style)
# Replace example domains with your real CDNs/APIs.

add_header Content-Security-Policy-Report-Only "
  default-src 'self';
  base-uri 'self';
  object-src 'none';
  frame-ancestors 'self';
  img-src 'self' data: https:;
  font-src 'self' https: data:;
  style-src 'self' https: 'unsafe-inline';
  script-src 'self' https: 'nonce-$request_id';
  connect-src 'self' https:;
  upgrade-insecure-requests;
  report-to csp-endpoint;
";

# Report-To header defines where reports go (example only).
# You can also use report-uri for legacy collectors.
add_header Report-To '{"group":"csp-endpoint","max_age":10886400,"endpoints":[{"url":"https://example.com/csp-report"}]}' ;
About style-src 'unsafe-inline'

Inline styles are less dangerous than inline scripts, but they can still matter in some attack chains. Many apps keep 'unsafe-inline' for styles early to avoid breaking UI, then migrate to nonces/hashes or external stylesheets later. Avoid 'unsafe-inline' for script-src in production.

Step 3 — Collect and triage CSP violation reports

CSP reports can be noisy. The trick is to group them by “blocked directive + blocked URI + page”. Fix the highest-impact issues first: inline scripts, unexpected script domains, and missing connect-src entries.

Common report categories

  • Inline script blocked: you need nonces/hashes or to remove inline JS
  • External script blocked: missing domain in script-src
  • Fetch/WebSocket blocked: missing domain in connect-src
  • Fonts blocked: missing font CDN in font-src

How to avoid chasing ghosts

  • Separate dev vs prod policies (dev tools often use eval)
  • Ignore browser extensions and local injections in reports
  • Focus on high-traffic routes first (home, login, checkout)
  • Document approved third-party hosts (avoid “allow https:”)

Step 4 — Add nonces properly (server + templates)

A nonce must be random and per-response. The server sends the nonce in the CSP header, and any inline script that should execute includes nonce="...nonce...". If an attacker injects a script, they won’t know the nonce and the browser blocks execution.

Below is a simple Node/Express example that sets a nonce and renders it into HTML. The important part is: nonce is generated per request, included in the header, and used on script tags.

import crypto from "crypto";
import express from "express";

const app = express();

app.use((req, res, next) => {
  // Per-response nonce (base64 is common)
  const nonce = crypto.randomBytes(16).toString("base64");
  res.locals.cspNonce = nonce;

  // Minimal example CSP. Expand with your real hosts.
  res.setHeader(
    "Content-Security-Policy",
    [
      "default-src 'self'",
      "base-uri 'self'",
      "object-src 'none'",
      "frame-ancestors 'self'",
      `script-src 'self' 'nonce-${nonce}'`,
      "style-src 'self' https: 'unsafe-inline'",
      "img-src 'self' data: https:",
      "connect-src 'self' https:",
    ].join("; ")
  );

  next();
});

app.get("/", (req, res) => {
  // Example HTML output. In a real app use your template engine.
  res.type("html").send(`
    <!doctype html>
    <html>
      <head><meta charset="utf-8"></head>
      <body>
        <h1>Hello</h1>
        <script nonce="${res.locals.cspNonce}">
          window.__BOOT__ = { ok: true };
        </script>
        <script src="/app.js"></script>
      </body>
    </html>
  `);
});

app.listen(3000);
Nonce hygiene

Don’t reuse nonces across requests. Don’t store them in long-lived caches. If you have full-page caching, include the nonce at the edge or bypass cache for pages with inline scripts. Better yet: remove inline scripts over time.

Step 5 — Reduce “breakage risk” by removing inline scripts

The most stable CSP is one where you don’t need inline scripts at all. Move inline JS to bundled files, replace DOM event attributes (onclick="...") with event listeners, and avoid dynamic code evaluation.

Inline script migration checklist

  • Replace onclick/onload attributes with JS event listeners
  • Move tiny inline bootstraps into a small bootstrap.js file
  • Stop using string-to-code APIs (eval, new Function, setTimeout("..."))
  • Keep nonces only where unavoidable, and track them as tech debt

Step 6 — Enforce CSP and add a regression gate

Once Report-Only reports look clean on your highest-traffic pages, switch to enforcing mode. Keep Report-Only in parallel for a bit (with the same policy) to catch new third-party additions or build changes. Finally, add a CI gate so CSP doesn’t silently drift.

Here’s a small “CSP sanity check” script you can run in CI to detect inline scripts and event handler attributes. It’s not perfect, but it catches most regressions early.

import re
import sys
from pathlib import Path

INLINE_SCRIPT = re.compile(r"<script(?![^>]*\\bsrc=)[^>]*>", re.IGNORECASE)
INLINE_HANDLER = re.compile(r"\\son\\w+\\s*=\\s*['\\\"]", re.IGNORECASE)

def scan_file(p: Path) -> list[str]:
  txt = p.read_text(encoding="utf-8", errors="ignore")
  issues = []
  if INLINE_SCRIPT.search(txt):
    issues.append("inline <script> without src")
  if INLINE_HANDLER.search(txt):
    issues.append("inline event handler attribute (onclick/onload/...)")
  return issues

def main(root: str) -> int:
  root_path = Path(root)
  bad = 0
  for p in root_path.rglob("*.html"):
    issues = scan_file(p)
    if issues:
      bad += 1
      print(f"[CSP] {p}: " + ", ".join(issues))
  return 1 if bad else 0

if __name__ == "__main__":
  target = sys.argv[1] if len(sys.argv) > 1 else "."
  raise SystemExit(main(target))
Dev vs prod policies

Many dev tools (hot reload, source maps, older frameworks) rely on 'unsafe-eval' or inline scripts. Don’t carry those allowances into production. Maintain separate CSPs per environment.

Step 7 — Tighten policy over time

After you enforce a stable baseline, you can tighten CSP to increase protection: remove broad https: allowances, reduce third-party domains, lock down frames, and constrain connections. The best CSP is usually the one that matches your architecture: few script hosts, explicit API hosts, and minimal inline code.

Hardening step Why What to watch out for
Remove https: wildcards from script-src Prevents loading scripts from arbitrary HTTPS origins Hidden third-party scripts (tag managers, widgets)
Add strict connect-src Prevents exfil via fetch/XHR/WebSocket Telemetry, feature flags, auth endpoints
Use frame-ancestors Stops clickjacking and hostile embedding Legitimate embedding use cases
Reduce inline styles Improves overall strictness CSS-in-JS libraries and legacy templates

Common mistakes

CSP failures are usually not mysterious: they come from a handful of predictable patterns. Here are the most common CSP rollout pitfalls, and how to fix them without weakening the policy.

Mistake 1 — Starting in blocking mode

Going straight to Content-Security-Policy can break critical pages and force you to loosen the policy in panic.

  • Fix: start with Content-Security-Policy-Report-Only and collect reports.
  • Fix: roll out progressively (canary, % traffic, specific routes).

Mistake 2 — Using 'unsafe-inline' for scripts “temporarily”

Temporary allowances tend to become permanent, and they undermine XSS protection.

  • Fix: use nonces/hashes instead for the few inline scripts you can’t remove yet.
  • Fix: create a backlog item to eliminate inline scripts entirely.

Mistake 3 — Forgetting connect-src

The UI loads, but API calls fail silently or features break (auth, telemetry, websockets).

  • Fix: list all frontend network destinations and add them explicitly.
  • Fix: include wss: endpoints if you use websockets.

Mistake 4 — Allowing “everything over HTTPS”

Policies like script-src https: are easy, but they allow attacker-hosted scripts over HTTPS too.

  • Fix: allow only known script domains (CDN + vendor list).
  • Fix: prefer self-hosting critical third-party scripts when feasible.

Mistake 5 — Caching pages with a fixed nonce

A nonce must be per response. Cached HTML with one nonce can lead to breakage and weakens guarantees.

  • Fix: generate nonce at the edge or bypass cache for pages requiring inline scripts.
  • Fix: better: remove inline scripts so caching becomes simple again.

Mistake 6 — Not documenting third-party onboarding

Someone adds a marketing widget; CSP breaks; the “fix” becomes a permissive policy.

  • Fix: create an internal checklist: domain approval + CSP update + security review.
  • Fix: add CI checks for inline scripts and new hosts.
Debugging playbook

When something breaks after enabling CSP: open DevTools Console → click the CSP violation → note the directive and blocked URL. Then: add the minimal host to the minimal directive (don’t widen default-src), and verify the fix in report-only first.

FAQ

What is CSP and how does it prevent XSS?

CSP is a browser-enforced allowlist for resources. It tells the browser which script sources are allowed and whether inline scripts can run. If an attacker injects a script tag or inline JS, CSP can block execution unless it matches an allowed origin or has a valid nonce/hash. It reduces the impact of XSS, but you still need to fix the underlying injection bug.

Should I use <meta http-equiv="Content-Security-Policy"> or headers?

Use headers whenever possible. Headers are more robust, apply earlier, and are easier to manage consistently across routes. Meta CSP can be useful for static hosting without header control, but it’s more limited and easier to misconfigure.

What’s the safest CSP starting policy?

Start in Report-Only with a strict script-src plan. A common baseline is default-src 'self', object-src 'none', base-uri 'self', and a script-src that allows only your origin and known vendor domains. Then add nonces for any unavoidable inline scripts.

Why does CSP break my API calls and telemetry?

Because connect-src controls network destinations. If your frontend calls an API on a different domain, uses WebSockets, or sends telemetry to a collector, those hosts must be listed in connect-src. Without it, the browser blocks the requests.

Is 'unsafe-eval' ever OK?

In production, avoid it. Some dev tooling or older libraries require eval-like behavior, but it weakens protections and can enable certain injection chains. Keep 'unsafe-eval' only in development policies if you absolutely must, and ensure production builds don’t depend on it.

Nonce or hash: which should I choose?

Choose nonces for dynamic apps and hashes for static pages. Nonces are generated per response and scale well with templates/SSR. Hashes work when the inline script is stable and rarely changes. Both are far better than allowing all inline scripts.

Cheatsheet

A compact CSP checklist you can use during rollout, code review, and incident response.

Baseline CSP (good defaults)

  • default-src 'self' (then override per type)
  • object-src 'none'
  • base-uri 'self'
  • frame-ancestors 'self' (or explicit allowlist)
  • upgrade-insecure-requests

Script hardening (XSS focus)

  • Avoid 'unsafe-inline' in script-src
  • Use 'nonce-...' for any required inline scripts
  • Avoid 'unsafe-eval' in production
  • Allow only known script hosts (not https: wildcard)
  • Prefer bundling/self-hosting critical vendor scripts

Rollout plan

  • Start with Content-Security-Policy-Report-Only
  • Collect reports and fix top violations
  • Implement nonces/hashes for inline code
  • Enforce via Content-Security-Policy
  • Keep report-only alongside for a while (regression detector)

Troubleshooting shortcuts

  • Page loads, API fails: check connect-src
  • Fonts missing: check font-src
  • Images blocked: check img-src and data: usage
  • Inline script blocked: add nonce/hash or remove inline script
  • Site embeds fail: check frame-src and frame-ancestors

PR review questions

  • Did we add inline scripts or inline event handlers?
  • Did we introduce a new third-party domain? Is it necessary?
  • Is the new domain scoped to the right directive (script vs connect vs img)?
  • Does production still avoid 'unsafe-inline' for scripts and 'unsafe-eval'?
  • Did we test the page with CSP enforcing mode in staging?

Wrap-up

CSP is one of the most practical browser security controls: it changes XSS from “one injection = full compromise” into “injection that can’t easily execute or exfiltrate.” The key is to roll it out like a migration: report-only → fix → nonces/hashes → enforce → regressions.

Do this next (today)

  • Add CSP in Report-Only on your top pages
  • Collect violations and group by directive + blocked host
  • Fix missing connect-src entries and third-party script hosts
  • Plan nonce integration for any unavoidable inline scripts

Do this next (this week)

  • Remove inline scripts and event handler attributes
  • Enforce CSP in staging, then production with a canary rollout
  • Add CI checks to prevent inline regressions
  • Document third-party onboarding requirements (domains + review)
CSP pairs well with other defenses

CSP is strongest when combined with safe templating, output encoding, trusted types (where supported), and a strong security review process. If you haven’t done an XSS pass yet, start with your highest-risk DOM sinks and templates.

Next up: threat modeling your frontend surface, reviewing third-party scripts, and securing API endpoints against abuse. The related posts below cover those angles.

Quiz

Quick self-check (demo). This quiz is auto-generated for cyber / security / browser.

1) What is the primary security goal of a strict script-src CSP?
2) Why should most teams start CSP rollout with Content-Security-Policy-Report-Only?
3) What’s a correct statement about CSP nonces?
4) If your app loads but API calls fail after enabling CSP, which directive is the first to check?