Security IndexSecurity Headers

Content-Security-Policy (CSP)

What is Content-Security-Policy?

Content-Security-Policy (CSP) is an HTTP response header that tells the browser which content sources it should consider trusted. Every resource load — scripts, stylesheets, images, fonts, frames, media — can be controlled per-type.

Without CSP, an attacker who finds a single XSS vector can inject arbitrary JavaScript that runs with full access to cookies, localStorage, the DOM, and any API tokens the user holds. CSP shrinks the blast radius of an XSS to nothing — or at worst, to the specific sources you've explicitly allowed.

Why not just fix the XSS?

You should fix XSS bugs. CSP is defense-in-depth, not a substitute.

The problem is scale: modern web applications load dozens of third-party scripts, and any one of them could introduce an XSS vector you don't control. A compromised npm package, a malicious ad, a supply-chain attack on a CDN you trust — CSP limits the damage in all of these cases even when the XSS itself can't be fixed immediately.

Browsers with a strict CSP will refuse to execute injected scripts even when the injection succeeds.

How CSP works

The browser enforces CSP before executing any resource. When it encounters a script, style, or image request, it checks the CSP against:

  1. The scheme (https:, data:, blob:)
  2. The hostname (cdn.example.com)
  3. The full URL path (where specified)
  4. Whether inline code is allowed
  5. Whether a nonce or hash matches

If the resource doesn't match any allowed source, the browser blocks it. With report-uri or report-to configured, it also sends a violation report to your endpoint.

Directive reference

DirectiveControls
default-srcFallback for any fetch directive not explicitly set
script-srcJavaScript sources: <script> tags, event handlers, javascript: URLs
style-srcStylesheets: <style> tags, style= attributes, <link rel=stylesheet>
img-srcImage sources: <img>, CSS background-image, favicons
font-srcWeb fonts: @font-face sources
connect-srcfetch(), XMLHttpRequest, WebSocket, EventSource
media-src<audio>, <video>
object-src<object>, <embed>, <applet> — set to 'none' always
frame-src<iframe> sources
frame-ancestorsWhich origins can embed your page in a frame (replaces X-Frame-Options)
form-actionWhere forms can submit — prevents phishing via form hijacking
base-uriRestricts <base href> — prevents base tag injection
upgrade-insecure-requestsUpgrades http:// subresource requests to https:// automatically
worker-srcService workers, shared workers
manifest-srcWeb app manifests

Source values

ValueMeaning
'self'Same origin (scheme + hostname + port)
'none'No sources — block everything for this directive
'unsafe-inline'Allow inline code — voids XSS protection for that directive
'unsafe-eval'Allow eval(), Function(), setTimeout(string) — avoid if possible
'unsafe-hashes'Allow specific inline event handlers by hash
'nonce-<base64>'Allow a specific inline script/style that carries this nonce
'sha256-<hash>'Allow an inline block that matches this SHA-256 hash
'strict-dynamic'Trust scripts loaded by already-trusted scripts (nonce-propagating)
https:Any HTTPS source
data:Data URIs (restrict to img-src only)
blob:Blob URLs
example.comExact hostname (all paths, http + https)
*.example.comWildcard subdomain

unsafe-inline in script-src defeats CSP for XSS protection entirely. A policy with script-src 'self' 'unsafe-inline' provides zero XSS protection — an attacker can inject any inline script. If you need it for a legacy reason, add it to style-src only, never script-src.

Common misconfigurations

1. unsafe-inline in script-src

The single most common CSP mistake. Usually added because an existing inline script breaks with CSP:

# WRONG — provides no XSS protection
Content-Security-Policy: script-src 'self' 'unsafe-inline'

Solution: switch to nonces (see below) or move inline scripts to external files.

2. Wildcarding CDN domains

# WRONG — attacker can host malicious JS at cdn.jsdelivr.net/evil.js
Content-Security-Policy: script-src 'self' cdn.jsdelivr.net

If you're loading a specific library from a CDN, allow the specific path or switch to a hash or nonce.

3. Missing object-src

# WRONG — falls back to default-src, which often allows 'self'
Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-abc'

<object> and <embed> tags can load Flash/Java plugins that execute code outside CSP. Always set object-src 'none'.

4. Missing base-uri

Base tag injection (<base href="https://attacker.com">) redirects all relative URLs to the attacker's server, including script sources. Set base-uri 'self' or base-uri 'none'.

5. Missing frame-ancestors

CSP's frame-ancestors directive is the correct way to prevent clickjacking — X-Frame-Options is a legacy header that CSP supersedes. If you don't set frame-ancestors, you need X-Frame-Options separately.

6. report-only and never enforcing

CSP has a Content-Security-Policy-Report-Only mode that logs violations without blocking. This is useful during rollout, but many teams set it and forget it. Reports without enforcement provide zero protection.

A nonce is a random base64 value generated per request. Add it to your script-src directive and to every inline <script> tag. The browser only executes scripts that carry a matching nonce. Injected scripts (XSS) don't know the nonce, so they can't execute.

How nonces work

Content-Security-Policy: script-src 'self' 'nonce-r4nd0mb4se64=='
<!-- This executes — nonce matches -->
<script nonce="r4nd0mb4se64==">
  console.log('trusted');
</script>
 
<!-- This is blocked — no matching nonce -->
<script>alert('xss')</script>

The nonce must be:

  • Cryptographically random (use crypto.randomUUID() or crypto.getRandomValues())
  • Generated fresh per HTTP response — never reused or cached
  • At least 128 bits of entropy (a UUID encoded as base64 is fine)

Next.js implementation

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
 
export function middleware(request: NextRequest) {
  const nonce = Buffer.from(crypto.randomUUID()).toString('base64');
 
  const csp = [
    `default-src 'self'`,
    `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
    `style-src 'self' 'nonce-${nonce}'`,
    `img-src 'self' blob: data:`,
    `font-src 'self'`,
    `object-src 'none'`,
    `base-uri 'self'`,
    `form-action 'self'`,
    `frame-ancestors 'none'`,
    `upgrade-insecure-requests`,
  ].join('; ');
 
  const response = NextResponse.next({
    request: { headers: new Headers(request.headers) },
  });
 
  response.headers.set('Content-Security-Policy', csp);
  response.headers.set('x-nonce', nonce);     // pass to layout
  return response;
}
 
export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};
// app/layout.tsx
import { headers } from 'next/headers';
 
export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const headersList = await headers();
  const nonce = headersList.get('x-nonce') ?? '';
 
  return (
    <html lang="en">
      <body>
        <script nonce={nonce}>{`window.__NONCE__='${nonce}'`}</script>
        {children}
      </body>
    </html>
  );
}

'strict-dynamic' explained

'strict-dynamic' propagates trust to scripts dynamically loaded by a nonce-trusted script. This allows bundlers (webpack, Turbopack) to split code without requiring you to nonce every chunk:

script-src 'nonce-abc123' 'strict-dynamic'

With strict-dynamic, your nonce covers the root bundle, and the root bundle's dynamic imports inherit that trust automatically.

'strict-dynamic' ignores 'self' and 'unsafe-inline' in browsers that support it, making them no-ops. Include them as fallbacks for older browsers that don't support strict-dynamic.

Express implementation

// app.js
const helmet = require('helmet');
 
app.use((req, res, next) => {
  const nonce = require('crypto').randomBytes(16).toString('base64');
  res.locals.nonce = nonce;
 
  helmet.contentSecurityPolicy({
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'", `'nonce-${nonce}'`, "'strict-dynamic'"],
      styleSrc: ["'self'", `'nonce-${nonce}'`],
      imgSrc: ["'self'", 'data:', 'blob:'],
      fontSrc: ["'self'"],
      objectSrc: ["'none'"],
      baseUri: ["'self'"],
      formAction: ["'self'"],
      frameAncestors: ["'none'"],
      upgradeInsecureRequests: [],
    },
  })(req, res, next);
});

Hash-based CSP

If you have a small number of static inline scripts that never change, you can use SHA-256 hashes instead of nonces:

# Generate the hash of your inline script content
echo -n "console.log('hello')" | openssl dgst -sha256 -binary | openssl base64
# → YWJjMTIz...
Content-Security-Policy: script-src 'self' 'sha256-YWJjMTIz...'

Hashes work for static content but don't scale to dynamic scripts. Use nonces for those.

CSP violation reporting

Configure a reporting endpoint to receive violation reports without blocking:

# Enforce + report
Content-Security-Policy: default-src 'self'; report-uri /csp-report
 
# Report only — no blocking (use during rollout)
Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-report

A violation report looks like:

{
  "csp-report": {
    "document-uri": "https://example.com/page",
    "violated-directive": "script-src-elem",
    "blocked-uri": "https://evil.com/xss.js",
    "source-file": "https://example.com/page",
    "line-number": 42,
    "original-policy": "default-src 'self'"
  }
}

Use a service like report-uri.com or build a simple endpoint that logs to your observability stack.

Rollout strategy

  1. Start in report-only mode — deploy Content-Security-Policy-Report-Only with a strict policy and collect violations for 1–2 weeks
  2. Fix legitimate violations — inline scripts, missing CDN domains, eval() calls in dependencies
  3. Switch to enforcement — change the header to Content-Security-Policy
  4. Keep report-uri active — violations in enforcement mode still indicate real issues or emerging threats

Bypass techniques (know what you're defending against)

CSP is not a silver bullet. Common bypasses:

  • JSONP endpointsscript-src *.google.com allows loading any JSONP endpoint on Google's domains
  • AngularJS'unsafe-eval' or certain CDN-hosted AngularJS versions enable template injection bypasses
  • data: in script-src — allows <script src="data:text/javascript,alert(1)"> in some browsers
  • Open redirects on allowed domains<script src="https://allowed.com/redirect?url=https://evil.com/xss.js">

The CSP Evaluator tool from Google identifies many of these weaknesses automatically.

How PatchVex detects CSP issues

The PatchVex Web Scanner checks every HTTP response for:

  1. Presence — is the Content-Security-Policy header set?
  2. unsafe-inline in script-src — flags as high severity
  3. unsafe-eval in script-src — flags as medium severity
  4. Missing object-src — flags if fallback default-src doesn't restrict it
  5. Missing frame-ancestors — notes absence (overlaps with X-Frame-Options check)
  6. Missing base-uri — flags if absent
  7. Wildcard source values — flags * or scheme-only sources in sensitive directives
  8. Report-only without enforcement — notes if only report-only is set

Findings include the full policy string, the specific issue, and remediation steps.