Content Security Policy

· 6 min read · Updated March 30, 2026 · intermediate
javascript security web http xss

Content Security Policy is an HTTP response header that tells the browser exactly which resources it is allowed to load. Without it, a browser will load anything included in your HTML — including scripts from third-party CDNs, inline code, and data URIs. That flexibility is exactly what cross-site scripting attacks exploit. CSP closes that door by giving you explicit control over every resource that runs on your page.

It is not a silver bullet. A determined attacker who has found a way around CSP still has a problem. But CSP catches a wide range of script injection attacks automatically, and it does so on the client side — meaning it protects your users even if your server configuration is imperfect.

How CSP Works

The browser receives CSP as an HTTP header on the page response. It reads the directives, builds a list of allowed sources for each resource type, and then blocks anything that does not match. If a page tries to load a script from a domain not in the allowed list, the browser refuses to execute it and logs a violation.

A CSP header looks like this:

Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com

Each directive controls a specific type of resource. Directives not listed fall back to default-src. If neither is set, the browser uses its own defaults — which typically means everything is allowed.

You can also set CSP inside HTML using a <meta> tag, which is useful for single-page applications or environments where you do not control the server directly:

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

Note that frame-ancestors, report-uri, and report-to cannot be set via <meta> — they must come from an HTTP header.

Key Directives

default-src

This is your fallback. Any resource type without an explicit directive uses default-src to determine what is allowed. Setting default-src 'self' restricts all resources to your own origin by default.

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

If you set default-src 'self' and do not specify script-src, browsers block all script loading. That trips people up regularly — default-src does not automatically apply to every resource type unless you list it.

script-src

This is the most important directive for stopping XSS. It controls where scripts can be loaded from and whether inline <script> tags are permitted.

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

Common source values include:

  • 'self' — same origin only
  • 'none' — block everything
  • 'unsafe-inline' — allows inline <script> tags and inline event handlers. Using this defeats most of CSP’s XSS protection.
  • 'unsafe-eval' — allows eval(), setTimeout(string), and similar dynamic code execution
  • 'nonce-{base64-value}' — allows inline scripts only if their <script nonce="..."> tag contains the matching value
  • 'sha256-{base64-value}' — allows inline scripts whose content hashes to the specified value
  • HTTPS hostnames — allows scripts from those specific domains

style-src, img-src, font-src, connect-src, frame-src, media-src, object-src

Each controls the corresponding resource type with the same source-list syntax. A common pattern for fonts and styles:

Content-Security-Policy: style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com

The 'unsafe-inline' in style-src is common because CSS frameworks and component libraries often rely on it. The security risk is lower than unsafe-inline for scripts, but it still widens your attack surface.

frame-ancestors

Controls which pages can embed yours in an iframe. This replaces the older X-Frame-Options header and takes precedence over it.

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

form-action

Restricts where forms on your page can submit. An XSS payload that injects a fake form cannot point it anywhere outside the allowed list.

Content-Security-Policy: form-action 'self'

report-to and report-uri

When CSP blocks a resource, the browser sends a JSON report to a configured endpoint. report-to is the modern approach (CSP Level 3), while report-uri is still widely used despite being officially deprecated.

Content-Security-Policy: default-src 'self'; report-to csp-group

Violation reports look like this:

{
  "csp-report": {
    "blocked-uri": "https://evil.com/loader.js",
    "violated-directive": "script-src",
    "original-policy": "default-src 'self'; script-src 'self'",
    "document-uri": "https://yourapp.com/page"
  }
}

Your server must accept POST requests to the report endpoint, not just GET.

A Practical Implementation in Express

Here is how you wire CSP into an Express application with per-request nonces:

const crypto = require('crypto');

app.use((req, res, next) => {
  // Generate a fresh nonce for every response
  const nonce = crypto.randomBytes(16).toString('base64');

  res.setHeader('Content-Security-Policy', [
    `default-src 'self'`,
    `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
    `style-src 'self' 'unsafe-inline'`,
    `img-src 'self' data:`,
    `report-to csp-group`
  ].join('; '));

  // Make the nonce available to templates
  res.locals.nonce = nonce;
  next();
});

Any template that renders a script tag must include the nonce:

// In your template (EJS, for example)
<script nonce="<%= nonce %>">
  console.log('This runs because the nonce matches');
</script>

'strict-dynamic' (CSP Level 3) tells the browser that scripts loaded by a trusted script are themselves trusted. This means you do not have to whitelist every script that your main bundle dynamically imports — the trust propagates automatically.

Common Gotchas

Nonces Must Change Per Request

A static nonce is no better than unsafe-inline. An attacker who learns the nonce value can bypass your policy just as easily. Generate a new nonce on every page load and inject it into both the header and every <script nonce> tag on that page.

Hashes Must Match Exactly

When you use 'sha256-{value}', the hash must be a Base64-encoded SHA-256 hash of the exact script content — including every space and newline. A trailing newline mismatch silently blocks the script. Most developers use nonces for this reason; hashes are harder to get right.

default-src Is Not a Wildcard

Setting default-src 'self' and forgetting script-src will block all scripts, including your own bundle. You need explicit directives for every resource type your page actually uses.

Meta Tags Cannot Do Everything

If you need frame-ancestors or violation reporting, you must use an HTTP header. Attempting to use report-uri, report-to, or frame-ancestors inside a <meta> tag is silently ignored.

unsafe-inline and unsafe-eval Exist for Legacy Compatibility

Frameworks that generate inline scripts need these values to work without refactoring. If you control your codebase, remove them. Use nonces or hashes instead. Inline event handlers like <button onclick="..."> are also blocked by default, which breaks some old patterns.

CSP Level 3 and strict-dynamic

CSP Level 3 added strict-dynamic, which simplifies allowlisting for applications that load scripts dynamically. With a nonce-based policy:

Content-Security-Policy: script-src 'nonce-dG9nZXJhdG9y' 'strict-dynamic'; base-uri 'self'

A trusted script with the correct nonce can load additional scripts without you having to whitelist their origins. This works well with modern bundlers that use dynamic import() for code splitting.

Browser support for Level 3 is solid: Chrome 73+, Firefox 91+, and Safari 15.4+ all handle strict-dynamic.

See Also