Skip to content

Content Security Policy Deep Dive

expert22 min read

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.

Mental Model

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) */
  navigate-to 'self';              /* restrict navigation targets */

Reporting

  report-uri /csp-violation;       /* where to send violation reports (deprecated) */
  report-to csp-endpoint;          /* newer reporting API */
Quiz
If a CSP sets default-src to self and does not include an explicit script-src, what happens when the page tries to load an external script?

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:

  1. Any script on cdn.example.com can execute — including JSONP endpoints or uploaded files
  2. If analytics.example.com has any XSS or open redirect, the attacker inherits your trust
  3. 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.

Quiz
Why is a domain-based script-src (like allowing a CDN domain) considered weak?

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-src are 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 createElement are 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 trusted

strict-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.

Quiz
When strict-dynamic is set in the CSP, what happens to domain allowlists in script-src?

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

NonceHash
Inline scripts change per-requestYes (nonce changes each time)No (hash changes with content)
External scriptsAdd nonce attributeNot applicable
Server-side renderingMust inject nonce into HTMLComputed at build time
Static sites / CDNHarder (need edge compute)Easier (content is static)
Third-party scriptsAdd nonce to their tagsCan'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'.

Quiz
An attacker injects a base tag pointing to their server. What does this affect?

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

  1. Deploy in report-only mode
  2. Collect reports for 2-4 weeks
  3. Filter out noise (browser extensions trigger many false positives)
  4. Fix legitimate violations (your own resources that need nonces)
  5. Switch to enforcement
  6. Keep report-uri active to catch regressions
Browser extension noise

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 doWhat 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
Challenge: Write a strict CSP for a static documentation site

Try to solve it before peeking at the answer.

text

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
Key Rules
  1. 1Use nonce-based CSP with strict-dynamic — domain allowlists are bypassable in over 94% of cases
  2. 2Always set object-src to none and base-uri to self — these are the most common forgotten bypass vectors
  3. 3Deploy in report-only mode first and monitor for 2-4 weeks before enforcing
  4. 4Filter browser extension violations from CSP reports — they are noise, not attacks
  5. 5Nonces must be cryptographically random and unique per response — predictable nonces are equivalent to no CSP
  6. 6strict-dynamic ignores domain allowlists in modern browsers — include them only as fallbacks for older browsers