toolhq.io

All posts
8 min readby Jameel Haider

Content Security Policy explained: what CSP blocks and how to write one

A Content Security Policy is a response header that tells the browser which sources it is allowed to load code, styles, images, and frames from. Everything else is refused before it executes. It exists because the browser cannot tell the difference between a script you wrote and a script an attacker injected into your page: both arrive as valid HTML. CSP restores that distinction by making you declare, in advance, what legitimate looks like.

The header is a list of directives separated by semicolons:

Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example.com; object-src 'none'; base-uri 'self'

You can read the policy any site currently sends, along with the rest of its security headers, using the HTTP Headers tool.

What does a CSP actually block?

CSP is an allowlist enforced at load time. When the browser encounters a resource, it checks the matching directive; if the source is not listed, the resource never loads and a violation is logged to the console. The directives worth knowing:

DirectiveControlsTypical value
default-srcFallback for most fetch directives'self'
script-srcJavaScript, including inline and eval'self' plus a nonce
style-srcStylesheets and inline styles'self'
img-srcImages, favicons'self' data: https:
connect-srcfetch, XHR, WebSocket, EventSource'self' plus your API origins
font-srcWeb fonts'self'
frame-srcWhat you may embed'none' unless you embed
frame-ancestorsWho may embed you'self' or 'none'
form-actionWhere forms may submit'self'
base-uriWhat <base> may be set to'self'
object-srcFlash era plugins'none', always

Two of these are easy to skip and cost you the most when omitted. base-uri stops an injected <base href="https://attacker.example"> from silently repointing every relative script URL on the page. form-action stops an injected form from posting your users' credentials somewhere else. Neither falls back to default-src, so an unlisted directive means unrestricted.

frame-ancestors deserves special mention: it is the modern replacement for X-Frame-Options and the way you prevent clickjacking. Where the two disagree, browsers follow frame-ancestors.

Why does unsafe-inline defeat the purpose?

The single most common CSP in the wild looks like this:

Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'

That policy stops almost nothing. Cross site scripting overwhelmingly works by injecting inline script, an onerror attribute, or a javascript: URL into the page, and 'unsafe-inline' permits exactly that. Adding 'unsafe-eval' allows string to code conversion on top. A policy carrying both is a policy that blocks remote script from unlisted domains and nothing else.

Removing 'unsafe-inline' is the real work of adopting CSP, and there are two supported ways to do it.

Nonces. Generate a fresh random value per response, put it in the header and on every legitimate script tag:

Content-Security-Policy: script-src 'nonce-r4nd0mBase64' 'strict-dynamic' https:
<script nonce="r4nd0mBase64">/* your inline code */</script>

The nonce must be unpredictable and different on every response, which means the page cannot be cached as static HTML unless the nonce is injected at the edge. Generating a suitable value is exactly what a random token generator produces: at least 128 bits from a cryptographic source, never a counter or a timestamp.

Hashes. For inline scripts that never change, take the SHA-256 of the script body and list it:

Content-Security-Policy: script-src 'sha256-K9m2...='

This keeps the page fully cacheable. You can compute the digest with the Hash Generator; hash the exact bytes between the tags, with no surrounding whitespace differences.

'strict-dynamic' is the piece that makes nonces practical at scale. It says: any script that passed the nonce check may load further scripts of its own. That removes the need to enumerate every analytics and tag manager domain your bundle pulls in, which is where domain allowlists usually collapse.

How do you roll one out without breaking the site?

Deploy in report only mode first. The browser evaluates the policy, reports every violation, and enforces nothing:

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

Violations arrive as JSON POSTs to your endpoint. Collect them for a week or two of real traffic, because the long tail of third party widgets, email link scanners, and browser extensions will surface things staging never does. A webhook endpoint is a quick way to inspect the report payloads before you build the real collector.

A workable sequence:

  1. Ship report only with your intended strict policy.
  2. Triage reports. Distinguish genuine gaps in your policy from browser extension noise, which is common and safe to ignore.
  3. Fix the source rather than the policy where you can: move inline handlers into external files, drop eval based templating.
  4. Switch the header to enforcing mode, keeping report-uri in place so regressions still surface.
  5. Keep Report-Only available for testing tighter policies afterwards.

Both headers can be sent at once: one enforcing your current policy, one testing the next one.

What CSP does not do

CSP is a mitigation layer, not a fix. It reduces the impact of an injection that already happened; it does not stop the injection. Output escaping, templating that escapes by default, and validating untrusted input remain the actual defence. A strict CSP also does nothing about server side issues, request forgery, or leaked credentials.

It also pairs with, rather than replaces, the other security headers: Strict-Transport-Security to lock the connection to HTTPS, Referrer-Policy to control what leaks in the Referer header, and X-Content-Type-Options: nosniff so the browser respects your declared MIME types. Check what your origin currently sends with the HTTP Headers tool, then close the gaps one directive at a time.