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-Onlyfirst (no blocking yet) - Set a
report-togroup (orreport-urias 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
nonceattributes 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
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 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 typesscript-src: where scripts can load from (most important)style-src: where styles can load fromimg-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': allowseval/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 |
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"}]}' ;
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);
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/onloadattributes with JS event listeners - Move tiny inline bootstraps into a small
bootstrap.jsfile - 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))
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-Onlyand 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.
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'inscript-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-srcanddata:usage - Inline script blocked: add nonce/hash or remove inline script
- Site embeds fail: check
frame-srcandframe-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-srcentries 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 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.