XSS has been in the OWASP Top 10 for two decades for a single reason — guaranteeing every byte of user input is sanitized is almost impossible. One leak anywhere and you're done. Content Security Policy (CSP) is the last line of defense: even if a malicious script ends up in the page, the browser refuses to run it. This guide covers how CSP actually works, which directives to set, and how to coexist with AdSense and Analytics.
What CSP blocks
For every resource the page tries to load (scripts, styles, images, fonts, iframes, fetch, media), the browser checks the origin against your policy. Off-list origins are blocked and reported in DevTools.
- Inline scripts:
<script>alert(1)</script>— blocked by default. - Inline event handlers:
<a onclick="...">— blocked. - External scripts:
<script src="evil.com/x.js">— blocked unless the source is allowed in script-src. - eval / Function — blocked by default; needs
'unsafe-eval'.
Real-world flow: a stored XSS lands a <script> on the page → the browser refuses to execute it → the attack is neutralized.
Header format
Content-Security-Policy: default-src 'self'; script-src 'self' https://www.googletagmanager.com; img-src 'self' data: https:; style-src 'self' 'unsafe-inline'; connect-src 'self' https://api.example.com; frame-ancestors 'none'Directives plus their allowed sources, separated by ;. default-src is the fallback when a specific *-src isn't set.
Main directives
| Directive | Controls | Typical value |
|---|---|---|
default-src | Fallback for other *-src | 'self' |
script-src | JS sources | 'self' 'nonce-XYZ' 'strict-dynamic' |
style-src | CSS sources | 'self' 'unsafe-inline' |
img-src | Images | 'self' data: https: |
connect-src | fetch/XHR/WebSocket | 'self' https://api.example.com |
font-src | Fonts | 'self' https://fonts.gstatic.com |
frame-src | iframe contents | https://www.youtube.com |
frame-ancestors | Who can iframe this page (clickjacking defense) | 'none' or 'self' |
form-action | Form submit targets | 'self' |
base-uri | Allowed <base> values | 'self' or 'none' |
upgrade-insecure-requests | Auto-upgrade http to https | (no value) |
Inline scripts — three options
1. nonce (recommended)
Generate a random nonce per request and place it in both the header and the <script nonce>. An injected script without the nonce won't run.
Content-Security-Policy: script-src 'self' 'nonce-r4nd0m123'
<script nonce="r4nd0m123">
// This is the only inline script that executes.
</script>The nonce should be 16+ bytes of secure random, fresh each request. Hardest on static sites; natural in Next.js/SSR.
2. hash
Put a SHA-256 hash of the script body in the header. Same content, same hash → allowed.
Content-Security-Policy: script-src 'sha256-AbCdEf...='
<script>
console.log("hello");
</script>Pro: static-site friendly. Con: any change to the script changes the hash — your build pipeline must compute it.
3. unsafe-inline (avoid)
Allows all inline scripts — effectively no CSP. Don't use except for legacy code paths. 'unsafe-inline' is sometimes acceptable for styles (low XSS impact); never for scripts.
strict-dynamic — playing well with libraries
Anything loaded by a nonced script is also allowed automatically. Useful for libraries (Google Analytics, Stripe.js) that load sub-scripts at runtime.
Content-Security-Policy: script-src 'nonce-r4nd0m' 'strict-dynamic' https:The flow:
- The browser allows only the nonced script directly.
- Anything that script loads via
createElement("script")is allowed by strict-dynamic. - The http URL list is ignored —
https:stays as a fallback for browsers that don't support strict-dynamic.
This is the pattern Google's CSP Evaluator recommends. Use URL Parser to extract hosts from third-party URLs when assembling your allowlist.
Phased rollout — Report-Only
Get violation reports without breaking the site:
Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-reportThe header name is Content-Security-Policy-Report-Only. Violations are reported as JSON to /csp-report without blocking. Tune the policy from real logs, then promote to a hard Content-Security-Policy.
report-uri is deprecated in favor of report-to and the Reporting API. For now, send both.
Coexisting with AdSense / Analytics
Allow what they need:
script-src 'self' 'nonce-XYZ' 'strict-dynamic'
https://pagead2.googlesyndication.com
https://www.googletagmanager.com
https://www.google-analytics.com;
img-src 'self' data: https:;
connect-src 'self' https://www.google-analytics.com
https://pagead2.googlesyndication.com;
frame-src https://googleads.g.doubleclick.net
https://tpc.googlesyndication.com;The wildcard img-src https: exists because AdSense pulls ad creatives from a wide rotation of hosts. It's a trade-off — XSS payloads disguised as images get fewer constraints.
Common pitfalls
1. Starting with unsafe-inline
"I'll allow everything to avoid errors." Now the policy doesn't help. Move to nonce or hash.
2. script-src 'unsafe-eval' in production
webpack dev mode and some template engines use eval. Never enable in prod — eval is a giant attack surface.
3. A static nonce
The nonce must rotate per request. A constant nonce lets an attacker harvest it once and reuse forever.
4. Wildcards everywhere
script-src * is no CSP at all. Use wildcards only when truly necessary, scoped per directive.
5. Confusion with X-Frame-Options
X-Frame-Options: DENY and frame-ancestors 'none' do the same thing. CSP's version is the modern standard. Setting both is fine.
6. Forgetting local dev
HMR (Vite, Next.js dev) injects inline scripts and uses ws://. Use a permissive CSP in development and a strict one in production.
7. Going straight to enforce
Without Report-Only first, any false positive breaks real users. Roll out in Report-Only, get to zero violations, then enforce.
Trusted Types — the next layer
Chrome 83+ adds a constraint that sinks like innerHTML must receive vetted policy objects, not raw strings.
Content-Security-Policy: require-trusted-types-for 'script'; trusted-types defaultA real defense against DOM-based XSS. Safari and Firefox don't support it yet, but Chrome's share alone is meaningful. Validate compatibility with AdSense and other third-party scripts before enforcing.
Template for a small site + GA + external fonts
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-RANDOM' 'strict-dynamic' https:;
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://www.google-analytics.com;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
upgrade-insecure-requests;
report-to csp-endpoint;
report-uri /csp-report;Inspect response headers with HTTP Status Codes. Use Google's CSP Evaluator (csp-evaluator.withgoogle.com) to grade the policy.
Summary
- CSP = allowlist of origins for resources. XSS's last defense.
- Core directives: default-src / script-src / style-src / img-src / connect-src / frame-ancestors.
- Inline scripts use nonces (preferred) or hashes — avoid
'unsafe-inline'. - Third-party libraries pair with nonce + strict-dynamic.
- Start in Report-Only, get to zero violations, then enforce.
frame-ancestors 'none'doubles as clickjacking defense.