Guide

Content Security Policy (CSP) explained: headers and XSS defense

Cross-site scripting (XSS) lets attackers run JavaScript in your users' browsers — stealing session cookies, rewriting wallet prompts, or exfiltrating API keys. Input sanitization helps, but it is easy to miss an edge case. Content Security Policy (CSP) is a browser-enforced allowlist: you declare which origins may load scripts, styles, images, and network connections. Everything else is blocked before it executes. For publishers, SaaS dashboards, and wallet-connected dApps, a well-tuned CSP is one of the highest-leverage security headers you can add.

How CSP works

The server sends a Content-Security-Policy HTTP response header (or a <meta http-equiv="Content-Security-Policy"> tag, though headers are preferred). The browser parses a list of directives, each naming a resource type and an allowlist of sources.

Example minimal policy:

Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'

Here, all resources default to same-origin only. Scripts must come from your domain. Embedded plugins (object, embed) are disabled. base-uri stops injected <base href> tags from hijacking relative URLs — a subtle XSS amplifier.

When a page violates the policy, modern browsers block the resource and log a console error. In report-only mode (see below), violations are reported but not blocked — useful during rollout.

Directives you will actually use

CSP has dozens of directives; most apps need a focused subset:

  • default-src — fallback when a more specific directive is absent. Start restrictive: 'self'.
  • script-src — the heart of XSS defense. Controls <script>, inline handlers, and eval(). This is where you spend most tuning time.
  • style-src — CSS sources. Inline style="" attributes and <style> blocks need explicit permission or hashes.
  • img-src — images and favicons. Often 'self' data: https: for CDNs and data-URI avatars.
  • connect-srcfetch, XMLHttpRequest, WebSockets, and EventSource. Critical for SPAs calling APIs and dApps hitting RPC endpoints.
  • font-src — web fonts (Google Fonts, self-hosted).
  • frame-src / child-src — embedded iframes (payment widgets, OAuth popups).
  • frame-ancestors — who may embed your page in an iframe. Replaces the older X-Frame-Options header. Use 'none' or an explicit parent list to block clickjacking.
  • form-action — where HTML forms may submit. Stops injected forms from posting credentials to attacker domains.
  • upgrade-insecure-requests — rewrites http:// subresource URLs to HTTPS. Good default on production sites.

Source expressions

Each directive accepts space-separated source expressions:

  • 'self' — same scheme, host, and port as the document
  • https://cdn.example.com — exact origin allowlist
  • https: — any HTTPS origin (broad; use sparingly)
  • 'none' — block everything for that directive
  • 'unsafe-inline' — allow inline scripts/styles (defeats much of CSP's value — avoid if possible)
  • 'unsafe-eval' — allow eval() and new Function() (needed by some bundlers/dev tools; remove in production if you can)
  • 'nonce-abc123' — allow inline scripts/styles tagged with a matching nonce attribute
  • 'sha256-…' — allow a specific inline block by its hash

Stopping XSS: script-src strategies

The classic XSS pattern injects <script>stealCookies()</script> or an event handler like onerror= on an image tag. A strict script-src blocks unknown external scripts and, without 'unsafe-inline', blocks inline script too.

Nonce-based CSP (recommended for dynamic sites)

Your server generates a random nonce per request, includes it in the header and on each legitimate <script> tag:

Content-Security-Policy: script-src 'nonce-R4nd0mPerRequest'
<script nonce="R4nd0mPerRequest" src="/app.js"></script>

Injected scripts lack the nonce and are blocked. Frameworks like Next.js, Rails, and nginx with sub_filter can inject nonces automatically. The nonce must be unpredictable and single-use per response — never hard-code it.

Hash-based CSP (good for static sites)

For a small set of inline scripts, compute a SHA-256 hash of the exact script body and whitelist it:

script-src 'sha256-9d4Czl0…==' 'self'

Any change to the script body changes the hash — fine for immutable build artifacts, painful for frequently edited inline snippets.

Strict CSP without inline script at all

The gold standard: script-src 'self' with all JavaScript in external files, no inline handlers, no javascript: URLs. Static sites and well-structured SPAs can achieve this. Third-party widgets (analytics, AdSense, wallet SDKs) must be explicitly listed by origin.

Report-only mode and monitoring

Rolling out CSP on a legacy app breaks things. Use Content-Security-Policy-Report-Only first — same syntax, but violations are reported instead of blocked. Pair with report-uri (legacy) or report-to (modern) to send JSON violation reports to your logging endpoint.

Typical rollout:

  1. Deploy report-only policy, collect a week of violation logs.
  2. Fix false positives — add missing CDN origins, migrate inline scripts to files.
  3. Switch to enforcing Content-Security-Policy with the same rules.
  4. Keep report-to on the enforcing policy for ongoing monitoring.

Browser DevTools also surfaces violations in the Console tab — filter for "Content Security Policy" when debugging locally.

CSP for SPAs, APIs, and crypto dApps

Single-page apps and wallet-connected sites have extra connect-src requirements. Your policy must allow:

  • Your own API origin ('self' or https://api.example.com)
  • Third-party RPC providers (e.g. https://mainnet.helius-rpc.com) — list each host explicitly rather than wildcarding all HTTPS
  • WebSocket endpoints if you use real-time feeds
  • Analytics and ad networks if you run them — Google AdSense requires https://pagead2.googlesyndication.com in script-src and related ad domains in frame-src / img-src

Wallet browser extensions inject content scripts into your page. CSP does not block extension-injected scripts — extensions operate outside the page CSP. Your policy still protects against XSS that runs in the page context without extension involvement.

If you proxy RPC through your backend (recommended to hide API keys and avoid browser-side rate limits), tighten connect-src to your proxy only instead of exposing every RPC hostname to the client. See our Solana RPC endpoints guide for why that pattern matters.

CSP vs other security headers

CSP complements — does not replace — other defenses:

  • CORS controls whether other origins can read your API responses. CSP controls what your page can load. You need both. See CORS explained.
  • HttpOnly cookies keep session tokens out of JavaScript reach. CSP reduces the chance a stolen inline script runs at all.
  • Subresource Integrity (SRI) hashes external script files so a compromised CDN cannot swap code. Pair SRI with a strict script-src.
  • Referrer-Policy and Permissions-Policy limit metadata leakage and browser feature access (camera, geolocation).

For authenticated APIs, combine CSP on the frontend with short-lived JWT or OAuth tokens on the backend — defense in depth, not either/or.

Common mistakes and how to fix them

SymptomLikely causeFix
Inline script blocked No nonce/hash and no 'unsafe-inline' Move JS to external file, or add per-request nonce
Google Fonts CSS blocked style-src too strict Add https://fonts.googleapis.com to style-src; https://fonts.gstatic.com to font-src
API fetch fails silently Missing connect-src entry Add API and RPC origins explicitly; check DevTools Console
Site works in Chrome, breaks in Safari Browser-specific directive support Test both; avoid experimental directives in production
Policy seems ignored Meta tag cannot set report-uri or frame-ancestors Send CSP via HTTP header from nginx/Cloudflare instead

Avoid script-src * or blanket unsafe-inline unsafe-eval on production — scanners flag them, and they negate most XSS protection.

Example policies by site type

Static content site (no third-party scripts)

Content-Security-Policy:
  default-src 'self';
  script-src 'self';
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;
  font-src 'self';
  connect-src 'self';
  frame-ancestors 'none';
  base-uri 'self';
  form-action 'self'

Site with analytics and ads

Ad networks need explicit script and frame origins. Start from the vendor's documented CSP requirements, deploy report-only, then enforce. Allow https://pagead2.googlesyndication.com in script-src for AdSense; add their image and frame domains per Google's publisher documentation.

Solana dApp with backend RPC proxy

Content-Security-Policy:
  default-src 'self';
  script-src 'self';
  connect-src 'self' wss://your-api.example.com;
  img-src 'self' data: https:;
  frame-ancestors 'none'

Wallet popups and QR flows typically do not require widening frame-src on your origin — the wallet opens in extension UI or a separate app, not an iframe you embed.

Checklist before you ship

  • Send CSP via HTTP header, not only a meta tag.
  • Run report-only in staging; grep logs for unexpected blocked URIs.
  • List every third-party script, font, API, and RPC host explicitly.
  • Set object-src 'none' and base-uri 'self'.
  • Set frame-ancestors to block clickjacking.
  • Remove 'unsafe-eval' in production unless a dependency requires it.
  • Re-test after each new widget, A/B snippet, or CDN migration.
  • Document the policy in your repo so the next deploy does not silently widen it.

CSP is not a silver bullet — DOM-based XSS, compromised dependencies, and social engineering still happen. But a strict policy turns many injection bugs from "full account takeover" into "console error, attack blocked" — which is exactly the margin you want on a public-facing product.

Related reading