Content Security Policy (CSP) explained

By

Build and deploy a Content Security Policy with source directives, nonces or hashes, report-only rollout, framing protection, testing, and common CSP mistakes.

~~~

Content Security Policy, or CSP, tells the browser which resources a page may load and which script execution patterns it may use.

You normally send the policy in the Content-Security-Policy HTTP response header.

A strong policy makes Cross-Site Scripting harder to exploit. If an attacker injects a script, the browser checks it against the policy before running it.

CSP is a second layer. You still need output escaping, safe DOM APIs, input handling, dependency updates, and authorization.

The free Web Application Security course puts CSP beside the other browser and server controls it depends on.

The mental model

A page loads many kinds of resources:

CSP gives each kind a source list.

resource request -> matching directive -> allowed or blocked

For example:

Content-Security-Policy: default-src 'self'; img-src 'self' https://images.example.net

Most resource types can load only from the page’s own origin. Images can also load from https://images.example.net.

The browser enforces the policy. Your server only sends it.

Start with a baseline policy

Here is a useful starting point for a site whose assets come from its own origin:

Content-Security-Policy: default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'

Put it on one line in the actual header.

This policy says:

Do not paste it into production yet. First understand the directives and inventory what the site actually loads.

default-src is a fallback

default-src is the fallback for many fetch directives.

If img-src is absent, images use default-src. If img-src is present, it replaces that fallback for images.

Content-Security-Policy: default-src 'none'; img-src 'self'

This does not mean “block everything, then add same-origin images to it.” It means images use img-src 'self'; other covered resource types fall back to default-src 'none'.

Some directives do not fall back to default-src. frame-ancestors, base-uri, and form-action need their own values.

The directives you will use most

script-src

Controls JavaScript sources and important inline-script behavior:

Content-Security-Policy: script-src 'self' https://analytics.example.net

Host allowlists are easy to start with, but they can be weaker than they look. If an allowed host serves JSONP, user uploads, or a compromised script, that script inherits your trust.

Nonce- or hash-based strict policies provide a stronger script boundary.

style-src

Controls external stylesheets and inline styles:

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

Inline <style> blocks and style="" attributes are blocked unless the policy allows them through an appropriate nonce, hash, or inline policy.

A utility framework compiled into a normal same-origin CSS file works with 'self'. A library that injects <style> elements at runtime needs additional handling.

img-src

Controls images:

Content-Security-Policy: img-src 'self' data: https://cdn.example.net

Add data: only when the site uses data-URL images. Every allowed source expands the places an attacker might use for data exfiltration.

connect-src

Controls browser-initiated connections such as fetch, XMLHttpRequest, WebSocket, and EventSource:

Content-Security-Policy: connect-src 'self' https://api.example.net wss://socket.example.net

If an API call suddenly fails after CSP enforcement, inspect connect-src and the browser console.

frame-src

Controls which frames the page can load:

Content-Security-Policy: frame-src https://payments.example.net

This is different from frame-ancestors, which controls who may frame your page.

frame-ancestors

Block framing:

Content-Security-Policy: frame-ancestors 'none'

Or allow only your own origin:

Content-Security-Policy: frame-ancestors 'self'

This is the modern CSP control for clickjacking. I cover the related response headers in HTTP security headers.

base-uri

Restrict the HTML <base> element:

Content-Security-Policy: base-uri 'self'

An injected <base> can change how relative script, form, and link URLs resolve. Strict CSP examples include base-uri for this reason.

form-action

Restrict form destinations:

Content-Security-Policy: form-action 'self' https://checkout.example.net

form-action does not inherit from default-src, so set it explicitly on pages with forms.

Why 'unsafe-inline' is weak

This policy allows every inline script:

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

That includes an injected <script> block and inline event handlers such as onclick.

The policy can still restrict external origins, but its XSS protection is much weaker.

Use external scripts, nonces, or hashes instead of adding 'unsafe-inline' as the first fix.

Use a nonce for dynamic HTML

A nonce is a random value created for one HTTP response.

Put it in the policy:

Content-Security-Policy: script-src 'nonce-4n0Tq8c6Yz2mQw'

Put the same value on scripts you trust in that response:

<script nonce="4n0Tq8c6Yz2mQw" src="/app.js"></script>

The browser runs scripts with the matching nonce.

The example value is short for readability. Generate a cryptographically random, unpredictable value for every response.

In Node.js:

import { randomBytes } from 'node:crypto'

const nonce = randomBytes(16).toString('base64')

Then build both the header and HTML from that server-side value.

Never reuse one nonce across responses. Never put a fixed nonce in a static configuration file. A predictable nonce stops being an authorization signal.

Nonce-based HTML also affects caching. If a CDN caches the page, the cached header and HTML must contain the same nonce. Generate them together at the layer that owns the response.

Use hashes for stable inline scripts

A static site cannot create a new nonce per response without dynamic middleware.

For a stable inline script, CSP can allow the hash of its exact content:

<script>
  document.documentElement.classList.add('js')
</script>

Generate a SHA-256 hash of the script body and add it to script-src:

Content-Security-Policy: script-src 'self' 'sha256-BASE64_HASH_HERE'

Whitespace and punctuation are part of the hash. Changing the script requires a new header value.

Hashes work well for small static boot scripts. Moving the code into /app.js is often easier.

A strict script policy

CSP Level 3 defines a strict policy around nonces or hashes with 'strict-dynamic':

Content-Security-Policy: script-src 'nonce-RANDOM_VALUE' 'strict-dynamic'; base-uri 'self'; object-src 'none'

In supporting browsers, a trusted nonce-bearing script can load additional scripts. Host and scheme sources in the same directive are ignored for script loading under 'strict-dynamic'.

This works well for applications with a trusted loader or modern framework. It is not automatically the best policy for a small static site.

Choose one script trust model and test it. A long list containing 'self', many hosts, a nonce, 'unsafe-inline', and 'strict-dynamic' is hard to reason about across browser versions.

Inline event handlers need removal

This HTML is inline script:

<button onclick="save()">Save</button>

A strict policy blocks it.

Move the behavior into a trusted script:

<button id="save-button">Save</button>
document
  .querySelector('#save-button')
  .addEventListener('click', save)

Removing inline handlers improves both CSP and code organization.

Inventory the page before writing the policy

Open the browser network panel and list every resource origin.

Classify each one:

script     -> script-src
stylesheet -> style-src
image      -> img-src
font       -> font-src
fetch/SSE  -> connect-src
iframe     -> frame-src
form       -> form-action

Then ask whether each third party is needed.

CSP is a good reason to remove unused analytics, widgets, font hosts, and tag-manager containers. A shorter source list is easier to understand and harder to abuse.

Do not add https: or * to silence reports. That turns a useful allowlist into a broad network permission.

Roll out with report-only mode

Use Content-Security-Policy-Report-Only to observe violations without blocking them:

Reporting-Endpoints: csp="https://reports.example.net/csp"
Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; report-to csp

The browser sends reports to the named endpoint.

Report delivery is best effort. It is telemetry, not a complete security log.

Reports can contain page URLs, resource URLs, and other details. Protect the endpoint, limit body size, rate-limit it, and decide how long to retain the data.

The older report-uri directive is deprecated, but it can still be useful as a compatibility fallback while report-to support varies:

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

Check current browser support for the audience you serve.

Report-only is not enforcement

A report-only policy does not block an attack.

Use it to discover breakage, fix the policy, then send an enforcing Content-Security-Policy header.

You can run both headers together during migration:

Content-Security-Policy: current enforced policy
Content-Security-Policy-Report-Only: stricter candidate policy

This lets you tighten one step at a time.

Test pages, not only the home page

Different routes load different resources.

Test:

If the app uses SSE for LLM responses, the stream URL must be allowed by connect-src.

Open browser developer tools and look for CSP violations. Then run functional tests. A missing violation does not prove the user flow still works.

Send CSP as a header

CSP can also appear in a <meta http-equiv> element, but the header is the better default.

Some directives, including frame-ancestors, do not work from a meta-delivered policy. A header also applies before the browser parses document content.

Configure it at the application, reverse proxy, or hosting platform that serves the HTML response.

Do not attach an HTML CSP header to unrelated JSON, image, or font responses and assume it protects the page. The policy belongs on the document.

Multiple policies only get stricter

A response can contain multiple CSP policies. The browser enforces all of them.

They do not merge into one permissive union.

If one policy allows a script host and another blocks it, the load is blocked. This surprises teams that configure one policy in the app and another at the CDN.

Inspect the final production response headers:

curl -I https://example.com/

The deployed response is the source of truth.

Common mistakes

Treating CSP as an XSS fix

Fix the injection. CSP limits the damage when another defense fails.

Adding 'unsafe-inline' without a plan

Move handlers and scripts, or use nonces and hashes.

Reusing a nonce

A nonce must be unpredictable and unique per response.

Forgetting base-uri

An injected <base> can redirect relative URLs even when scripts are constrained.

Confusing frame-src and frame-ancestors

frame-src controls frames you load. frame-ancestors controls sites that frame you.

Allowing broad schemes

script-src https: trusts scripts from every HTTPS origin. HTTPS protects transport, not the script’s author.

Trusting report-only forever

It observes. It does not block.

Ignoring browser extensions

Extensions can create noisy reports that do not represent your deployed code. Group reports carefully before changing the policy.

Breaking the site and disabling CSP

Roll out gradually. A small enforced policy is better than an ambitious policy removed after one incident.

How I would deploy CSP

I would start by removing third-party scripts I do not need.

Then I would write a baseline policy from the actual network inventory and run it in report-only mode. I would test the important routes myself instead of waiting only for reports.

For a static site, I would prefer external same-origin scripts and hashes for the few inline blocks that remain. For a dynamic application, I would consider a per-response nonce.

I would enforce object-src, base-uri, frame-ancestors, and a realistic source policy first. Then I would tighten scripts and styles as the application allowed.

I would not weaken the whole site for one widget. I would remove the widget, isolate it, or give its exact sources the narrowest directives it needs.

CSP works best when it reflects a simple page. The policy is not only a header. It is a map of what your browser application trusts.

Want me to talk about your product? You can sponsor this site.

~~~

Related posts about network: