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

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.