Skip to content

Content Security Policy

intermediate18 min read

Your Page Has a Bouncer Problem

Here is the uncomfortable truth about frontend security: by default, your page trusts everything. Any script can run. Any image can load. Any connection can fire. The browser does not care if you intended to load that script from sketchy-cdn.biz or if an attacker injected it through a form field.

Content Security Policy (CSP) fixes this. It is an HTTP header (or meta tag) that tells the browser: "Here is an explicit list of sources I trust. Block everything else."

Think of it like a nightclub bouncer with a guest list. No list, no entry. Even if someone sneaks past your server-side validation and injects a malicious script tag into your HTML, the browser refuses to execute it because the script's source is not on the list.

Mental Model

Without CSP, your page is an open house party — anyone can walk in. With CSP, it is a private event with a guest list. The browser checks every resource (script, style, image, font, connection) against the list before allowing it. Resources not on the list get blocked, and the browser tells you about it.

How CSP Works

CSP works through directives — each one controls a specific type of resource. You send these directives as an HTTP response header, and the browser enforces them for the entire lifetime of that page.

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

That header says:

  • Scripts can only load from the same origin or https://cdn.example.com
  • Styles can only load from the same origin
  • Images can load from anywhere

Anything not matching these rules gets blocked. The browser logs a violation in the console, and if you have reporting configured, it sends the violation to your server.

Quiz
A page has the CSP header: script-src 'self'. An attacker injects a script tag pointing to evil.com/steal.js. What happens?

The Core Directives

You do not need to memorize every directive, but you need to know the ones that matter in practice. Here are the directives you will actually use:

default-src — The Fallback

default-src is the catch-all. Any directive you do not explicitly set falls back to default-src. This is why most CSP policies start here:

Content-Security-Policy: default-src 'self'

This single directive restricts everything — scripts, styles, images, fonts, connections, frames — to the same origin. It is the most restrictive starting point, and you loosen it from there.

script-src — The Most Important One

Controls where JavaScript can load from. This is the directive that prevents XSS. Common values:

  • 'self' — same origin only
  • 'nonce-abc123' — inline scripts with a matching nonce attribute
  • 'strict-dynamic' — trust scripts loaded by already-trusted scripts
  • https://cdn.example.com — specific external origin
  • 'unsafe-inline' — allows all inline scripts (defeats the purpose of CSP)
  • 'unsafe-eval' — allows eval(), Function(), setTimeout('string') (dangerous)

style-src — Stylesheet Control

Controls where CSS can load from. Same source values as script-src. The tricky part: many CSS-in-JS libraries inject inline styles, which means you might need 'unsafe-inline' for styles (less dangerous than for scripts, but still not ideal).

img-src — Image Sources

Controls where images can load from. Commonly set to * or a specific CDN domain. Data URIs need explicit data: in the source list.

connect-src — Network Connections

Controls where fetch(), XMLHttpRequest, WebSocket, and EventSource can connect. If your frontend talks to an API on a different domain, that domain must be listed here.

frame-ancestors — Who Can Embed You

This one is different — it controls which pages can embed your page in an iframe. Setting frame-ancestors 'none' is the modern replacement for the X-Frame-Options: DENY header. It prevents clickjacking attacks.

Content-Security-Policy: frame-ancestors 'self' https://trusted-partner.com
frame-ancestors is special

Unlike other directives, frame-ancestors does not fall back to default-src and cannot be set via a meta tag. It only works as an HTTP header.

Other Useful Directives

DirectiveControlsCommon Use
font-srcFont filesCDN or self
media-srcAudio and videoMedia CDN
object-srcPlugins (Flash, Java)Set to 'none' always
base-uriThe base tagSet to 'self' to prevent base tag injection
form-actionForm submission targetsPrevents form hijacking
upgrade-insecure-requestsUpgrades HTTP to HTTPSUse on HTTPS sites to catch mixed content
Quiz
Your CSP is: default-src 'self'; script-src https://cdn.example.com. Where can images load from?

Nonce-Based CSP for Inline Scripts

Here is the problem: modern frameworks often need inline scripts. Server-side rendering might inject a hydration script. Analytics snippets go inline. If you block all inline scripts with script-src 'self', your own code breaks.

The old solution was 'unsafe-inline' — but that defeats the purpose of CSP entirely because attackers inject inline scripts too.

The modern solution: nonces. A nonce is a random, single-use token that your server generates on every request. You put it in both the CSP header and the script tag. The browser only executes inline scripts with a matching nonce.

Content-Security-Policy: script-src 'nonce-a1b2c3d4e5f6'
<!-- This runs — nonce matches -->
<script nonce="a1b2c3d4e5f6">
  console.log('trusted inline script');
</script>

<!-- This is blocked — no nonce, or wrong nonce -->
<script>
  console.log('injected by attacker');
</script>

The key requirements for nonces:

  1. Generate a new nonce on every request — reusing nonces is a security vulnerability. Use a cryptographically random value (at least 128 bits)
  2. Send the nonce in the header AND the script tag — both must match
  3. Never expose the nonce in a URL or cacheable response — CDN-cached pages with nonces are dangerous because every user gets the same nonce
// Node.js / Express example
import crypto from 'crypto';

app.use((req, res, next) => {
  const nonce = crypto.randomBytes(16).toString('base64');
  res.locals.nonce = nonce;
  res.setHeader(
    'Content-Security-Policy',
    `script-src 'nonce-${nonce}'`
  );
  next();
});
Nonces vs hashes

CSP also supports hash-based allowlisting: script-src 'sha256-abc123...'. Instead of a nonce, you compute the SHA-256 hash of the inline script's contents and put it in the policy. The browser hashes the script and compares.

Hashes are useful for static inline scripts that never change (like a small polyfill). But they break the moment you change a single character in the script. For dynamic inline scripts (server-rendered hydration data, for example), nonces are far more practical.

One more thing: nonces work with strict-dynamic (covered next). Hashes do too, but nonces are the standard approach for modern CSP.

strict-dynamic — Trust the Trust Chain

Real-world apps load scripts dynamically. Your main bundle uses import() to lazy-load routes. A tag manager loads third-party scripts at runtime. With a basic script-src policy, you would need to list every possible script source — including ones you do not know at build time.

strict-dynamic solves this. When you add 'strict-dynamic' to script-src, the browser applies a simple rule: any script loaded by an already-trusted script is also trusted.

Content-Security-Policy: script-src 'nonce-abc123' 'strict-dynamic'

With this policy:

  1. Only scripts with nonce-abc123 are directly trusted
  2. If a trusted script uses document.createElement('script') to load another script, that dynamically loaded script is also trusted
  3. Host-based allowlists (https://cdn.example.com) are ignored when strict-dynamic is present
  4. 'unsafe-inline' is also ignored when strict-dynamic is present

This is powerful because it means your CSP policy does not need to know every CDN or third-party domain upfront. You just need to nonce your entry-point scripts, and their dynamic imports are automatically trusted.

Common Trap

When strict-dynamic is present, host-based source expressions like https://cdn.example.com and 'unsafe-inline' are ignored. This is intentional — Google designed it this way so you can include fallback values for older browsers that do not support strict-dynamic. Old browsers ignore the nonce and strict-dynamic but honor the host allowlist. New browsers honor the nonce and strict-dynamic and ignore the host allowlist. It is forward and backward compatible by design.

Quiz
Your CSP is: script-src 'nonce-xyz' 'strict-dynamic' https://cdn.example.com. A script tag without a nonce tries to load from cdn.example.com. What happens?

Reporting: Know When Violations Happen

CSP is not just a blocking mechanism — it is also a monitoring tool. You can configure the browser to report violations to an endpoint you control.

report-uri (Legacy)

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

The browser sends a JSON POST to /csp-report for every violation. The payload includes the violated directive, the blocked URI, and the page where it happened.

report-to (Modern)

The newer approach uses the Reporting API. You first define a reporting group, then reference it in your CSP:

Reporting-Endpoints: csp-endpoint="/csp-report"
Content-Security-Policy: default-src 'self'; report-to csp-endpoint

In practice, use both for compatibility:

Content-Security-Policy: default-src 'self'; report-uri /csp-report; report-to csp-endpoint

What Violation Reports Look Like

{
  "csp-report": {
    "document-uri": "https://example.com/page",
    "violated-directive": "script-src 'self'",
    "blocked-uri": "https://evil.com/steal.js",
    "status-code": 200,
    "original-policy": "script-src 'self'; report-uri /csp-report"
  }
}

This tells you exactly what was blocked and why. In production, you will see violations from browser extensions, ad injectors, and sometimes your own code that you forgot to allowlist. Reporting helps you distinguish real attacks from false positives before you enforce a strict policy.

Deploying CSP: The Report-Only Strategy

Here is where most teams get CSP wrong: they write a strict policy, deploy it to production, and immediately break their site. Login forms stop working. Third-party widgets disappear. Analytics scripts get blocked.

The correct approach is incremental deployment using report-only mode.

Step 1: Deploy in Report-Only Mode

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

Content-Security-Policy-Report-Only is a separate header that monitors without enforcing. The browser logs violations and sends reports, but does not block anything. Your site works exactly as before, but you now see what would break under the policy.

Step 2: Analyze Reports

Run in report-only for at least a week. You will discover:

  • Third-party scripts you forgot about (analytics, chat widgets, A/B testing)
  • Inline scripts that need nonces
  • Images loaded from unexpected CDNs
  • Browser extensions triggering false-positive violations

Step 3: Adjust Your Policy

Add legitimate sources to the allowlist. Replace 'unsafe-inline' scripts with nonce-based loading. Remove sources you do not actually need.

Step 4: Switch to Enforcement

Once reports are clean (or only showing expected noise like browser extensions), change the header from Content-Security-Policy-Report-Only to Content-Security-Policy. Keep reporting active — new violations may appear as your app evolves.

CSP via Meta Tags vs HTTP Headers

You can set CSP in two ways:

HTTP header (recommended):

Content-Security-Policy: default-src 'self'; script-src 'nonce-abc123'

Meta tag (limited):

<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'nonce-abc123'">

The meta tag approach has significant limitations:

  • frame-ancestors does not work in meta tags
  • report-uri and report-to do not work in meta tags
  • sandbox does not work in meta tags
  • The meta tag must be in <head> — if an attacker can inject content before your meta tag, CSP is useless
  • Multiple meta tags create independent policies (both must pass), which is confusing

Use HTTP headers whenever possible. Meta tags are a fallback for static hosting where you cannot control headers (like GitHub Pages), but they are strictly worse.

Quiz
You deploy CSP via a meta tag. You include frame-ancestors 'none' to prevent clickjacking. Is your page protected?

Common Mistakes That Break Your CSP

The two most dangerous CSP values are 'unsafe-inline' and 'unsafe-eval'. They exist for backward compatibility, but using them guts the security benefits of CSP.

unsafe-inline — The XSS Backdoor

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

This policy says "allow scripts from my origin... and also allow any inline script." An attacker who injects <script>alert('xss')</script> into your page wins. The entire point of CSP is to prevent this, and 'unsafe-inline' disables that protection.

The fix: use nonces instead. Every legitimate inline script gets a nonce. Injected scripts do not have the nonce and get blocked.

unsafe-eval — The Code Injection Backdoor

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

This allows eval(), new Function(), and setTimeout with string arguments. These are the exact functions attackers use to execute injected code. Some older libraries require eval (like certain template engines), but modern alternatives exist for nearly everything.

Overly Broad Allowlists

Content-Security-Policy: script-src 'self' https: data:

https: allows scripts from any HTTPS domain. data: allows data URI scripts. Both are effectively the same as having no CSP. An attacker just needs to host their payload on any HTTPS server.

Forgetting object-src

If you set script-src but not object-src, attackers can use <object> or <embed> tags to load malicious Flash or other plugin content. Always set object-src 'none' — there is no legitimate reason for plugins in modern web apps.

What developers doWhat they should do
Using unsafe-inline in script-src
unsafe-inline allows any injected script to execute, completely defeating CSP protection against XSS
Use nonce-based CSP for inline scripts
Using unsafe-eval in script-src
unsafe-eval lets attackers execute arbitrary code through eval-like functions — the exact attack vector CSP should prevent
Refactor code to avoid eval, new Function, and string-based setTimeout
Setting script-src to https: or *
Broad allowlists let attackers host payloads on any HTTPS domain and bypass your CSP entirely
Allowlist specific trusted domains only
Deploying strict CSP without report-only first
Jumping straight to enforcement will break third-party scripts, inline code, and widgets you forgot about
Run Content-Security-Policy-Report-Only for 1-2 weeks before enforcing
Setting CSP via meta tag and relying on frame-ancestors
Several important directives are silently ignored in meta tags, giving you a false sense of security
Use HTTP headers for CSP — meta tags ignore frame-ancestors, report-uri, and sandbox

Putting It All Together: A Production CSP

Here is a realistic CSP for a modern web app with a CDN, analytics, and server-rendered inline scripts:

Content-Security-Policy:
  default-src 'self';
  script-src 'nonce-{SERVER_GENERATED}' 'strict-dynamic';
  style-src 'self' 'unsafe-inline';
  img-src 'self' https://cdn.example.com data:;
  font-src 'self' https://fonts.gstatic.com;
  connect-src 'self' https://api.example.com;
  frame-ancestors 'none';
  object-src 'none';
  base-uri 'self';
  form-action 'self';
  report-uri /csp-report;
  report-to csp-endpoint

Breaking this down:

  • Scripts: nonce-based with strict-dynamic — no host allowlist needed for scripts
  • Styles: 'unsafe-inline' is used because CSS-in-JS libraries often require it (less dangerous for styles than scripts since CSS cannot execute arbitrary code)
  • Images: same origin, specific CDN, and data: for inline images
  • Fonts: same origin plus Google Fonts
  • Connections: same origin plus your API domain
  • Frames: nobody can embed your page
  • Objects: no plugins allowed
  • Base/Form: locked to same origin to prevent injection attacks
  • Reporting: both legacy and modern reporting configured
Quiz
In the production CSP above, style-src includes unsafe-inline. Is this a significant security risk?

CSP in Next.js

If you are using Next.js, you configure CSP through middleware. Here is the pattern:

// 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 cspHeader = `
    default-src 'self';
    script-src 'nonce-${nonce}' 'strict-dynamic';
    style-src 'self' 'unsafe-inline';
    img-src 'self' blob: data:;
    font-src 'self';
    object-src 'none';
    base-uri 'self';
    form-action 'self';
    frame-ancestors 'none';
  `.replace(/\n/g, '');

  const response = NextResponse.next();
  response.headers.set('Content-Security-Policy', cspHeader);

  // Pass nonce to the page via request headers
  response.headers.set('x-nonce', nonce);

  return response;
}

The nonce is generated per-request in middleware and passed to your components so they can add it to inline script tags. This works well with Server Components because middleware runs on the server for every request.

Key Rules
  1. 1Start with default-src 'self' and loosen from there — never the other way around.
  2. 2Never use unsafe-inline for scripts. Use nonces instead.
  3. 3Always set object-src 'none' — there is zero reason for plugins in modern web apps.
  4. 4Deploy in Content-Security-Policy-Report-Only mode first. Enforce only after reports are clean.
  5. 5Use strict-dynamic with nonces so dynamically loaded scripts are trusted without listing every CDN.
  6. 6Use HTTP headers for CSP, not meta tags. Meta tags silently ignore frame-ancestors, report-uri, and sandbox.
  7. 7Generate a new cryptographically random nonce on every request. Never reuse nonces.
  8. 8Keep reporting active even after enforcement — your app evolves, and new violations will appear.
Quiz
Your team deploys CSP with report-only mode and gets thousands of violation reports from browser extensions injecting scripts. What should you do?