Content Security Policy: What It Is and How to Implement It

Security & Architecture

Imagine you sanitise every user input, escape all output, and still find injected scripts running on your site. That scenario exists because XSS has many vectors — third-party libraries, browser extensions, dependency supply-chain attacks — that bypass input sanitisation entirely. Content Security Policy (CSP) is the browser-enforced safety net that limits what those injected scripts can actually do.

What CSP Does

CSP is an HTTP response header (or <meta> tag) that declares a whitelist of trusted content sources per resource type. If the browser encounters a resource that violates the policy, it blocks it — silently, or with a violation report.

Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example.com

This policy says: load everything from the same origin by default; load scripts only from the same origin or cdn.example.com. A <script src="https://evil.com/steal.js"> would be blocked even if it had been injected into the page.

The Key Directives

default-src — the fallback for any resource type not explicitly listed.

script-src — controls JavaScript. The most critical directive. Accepts:

  • 'self' — same origin
  • 'none' — nothing
  • A URL like https://cdn.example.com
  • 'nonce-<base64>' — allows a specific inline script with a matching nonce
  • 'strict-dynamic' — allows scripts loaded by trusted scripts (useful for script loaders)

style-src — CSS sources. Same values as script-src.

img-src — image sources.

connect-src — controls what URLs can be fetched via fetch, XHR, WebSocket.

frame-src / frame-ancestors — controls embedding. frame-ancestors 'none' is the modern equivalent of X-Frame-Options: DENY.

form-action — restricts where <form> elements can submit to.

Inline Scripts Are the Tricky Part

By default, a CSP with script-src 'self' blocks all inline scripts (<script>...</script>) and event handlers (onclick="..."). This breaks most existing applications immediately.

The naive escape hatch is 'unsafe-inline':

Content-Security-Policy: script-src 'self' 'unsafe-inline'

Don't do this. It defeats the primary XSS protection because it allows any injected inline script to execute.

The correct solution is nonces. The server generates a random value per request, includes it in the CSP header and on each legitimate inline script:

Content-Security-Policy: script-src 'nonce-r4nd0m4bc'
<script nonce="r4nd0m4bc">
  // This executes — nonce matches
  initApp()
</script>

An attacker can't know the nonce in advance (it changes every request), so injected scripts without the correct nonce are blocked.

Report-Only Mode: Deploy Without Breaking Things

The biggest barrier to CSP adoption is fear of breaking production. The Content-Security-Policy-Report-Only header solves this: violations are reported but not blocked.

Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-violations

Deploy in report-only mode, collect violations for a week, refine your policy, then switch to enforcement. This workflow makes CSP safe to adopt incrementally.

A Strict Modern Policy

The Google security team documented a CSP approach that works well with modern frameworks:

Content-Security-Policy:
  default-src 'none';
  script-src 'nonce-{random}' 'strict-dynamic';
  style-src 'self' 'nonce-{random}';
  img-src 'self' data:;
  connect-src 'self';
  font-src 'self';
  object-src 'none';
  base-uri 'none';
  form-action 'self';
  frame-ancestors 'none';

Key points:

  • 'none' as default-src denies everything not explicitly listed
  • 'strict-dynamic' allows scripts loaded by nonce-trusted scripts (handles dynamic imports and script loaders)
  • object-src 'none' blocks Flash and plugins (always do this)
  • base-uri 'none' prevents attackers from hijacking relative URLs with <base>
  • frame-ancestors 'none' prevents clickjacking

CSP and Next.js

Next.js supports CSP nonces through middleware:

// middleware.ts
import { NextResponse } from 'next/server'

export function middleware(request) {
  const nonce = Buffer.from(crypto.randomUUID()).toString('base64')
  const csp = `default-src 'self'; script-src 'nonce-${nonce}' 'strict-dynamic'`

  const response = NextResponse.next()
  response.headers.set('Content-Security-Policy', csp)
  response.headers.set('x-nonce', nonce)
  return response
}

Then pass the nonce to your <Script> components via headers().

What CSP Doesn't Prevent

CSP is a mitigation, not a silver bullet:

  • It doesn't prevent XSS injection — it limits what injected code can do
  • 'unsafe-eval' is often required by frameworks that use eval() — reduces effectiveness
  • Data exfiltration via img tags to attacker-controlled domains still works unless img-src is locked down
  • CSP doesn't help if the attacker compromises a trusted CDN

Use CSP alongside — not instead of — output encoding, input validation, HttpOnly cookies, and SameSite policies.

Conclusion

Content Security Policy is one of the highest-ROI security headers you can add to a web application. It takes XSS from "full account takeover" to "limited damage." The key is to avoid 'unsafe-inline', use nonces for legitimate inline scripts, and deploy in report-only mode first to catch violations before you break production.