A Content Security Policy is an HTTP header that lists which sources a browser may load scripts, styles, images and other resources from, and which inline code it may run. Written well, it is the strongest single defence against cross-site scripting, because even injected script will not execute unless it matches the policy the browser is enforcing.

CSP is powerful precisely because it moves trust from "any script on the page" to "only scripts the server explicitly blessed." That shift is also what makes it hard: a policy that is too loose protects nothing, and a policy that is too tight breaks your own site. This guide gives a working method rather than a directive dictionary.

How CSP thinks

A policy is a set of directives, each naming a resource type and the sources allowed for it. The browser enforces every directive on every matching resource and refuses anything not permitted. The key directives:

DirectiveControls
default-srcThe fallback for any fetch directive not set
script-srcWhere JavaScript may load and whether inline runs
style-srcWhere CSS may load
img-srcWhere images may load
connect-srcTargets for fetch, XHR and WebSocket
object-srcPlugins; almost always set to 'none'
base-uriWhat the page's <base> may be set to
frame-ancestorsWho may frame this page (anti-clickjacking)

Why host allowlists are the wrong default

The intuitive first policy lists trusted hostnames: script-src 'self' cdn.example.com. This feels safe but is fragile. If any allowed host serves a file that can be abused (an old library, a JSONP endpoint, an open redirect), an attacker can route injected script through it. Research on real-world policies has repeatedly shown host allowlists are commonly bypassable. The modern, robust approach is a nonce or hash.

Nonces

A nonce is a random value generated per response, placed on both the header and every legitimate <script> tag. Injected script has no way to know the nonce, so it will not run.

Content-Security-Policy: script-src 'nonce-Xy8s2Kd9'; object-src 'none'; base-uri 'self'
<script nonce="Xy8s2Kd9">/* your trusted inline script */</script>
The nonce must be cryptographically random and different on every response. A fixed or predictable nonce is no protection at all, because an attacker can simply copy it into their payload.

Hashes

For static inline scripts that never change, a hash of the script's exact contents works without a per-request value:

Content-Security-Policy: script-src 'sha256-B2yPHKaXnvFWtRChIbabYmUBFZdVfKKXHbWtWidDVF8='

Hashes are ideal for a small, fixed set of inline snippets; nonces suit dynamic pages. The related technique of pinning external files by hash is Subresource Integrity. The mechanics differ, CSP hashes cover inline code you control, SRI hashes cover external files a CDN serves, but the idea is the same: trust a specific piece of code by its exact contents rather than by where it came from.

A practical note on inline event handlers: attributes like onclick="..." and javascript: URLs cannot be covered by a nonce, because there is nowhere to put one. A strict CSP blocks them outright, which is a feature, not a bug, they are a common injection sink, but it means migrating any inline handlers in your markup to addEventListener in a nonce-approved script. Treat that migration as part of adopting CSP, not as an obstacle to it.

The strict-dynamic pattern

Modern apps load scripts that load more scripts. Listing every one is impractical. The 'strict-dynamic' keyword says: trust scripts that a nonce-approved script loads, and ignore host allowlists. A robust template:

Content-Security-Policy:
  script-src 'nonce-Xy8s2Kd9' 'strict-dynamic' https: 'unsafe-inline';
  object-src 'none';
  base-uri 'self';
  frame-ancestors 'self'

The https: and 'unsafe-inline' here are deliberate fallbacks that modern browsers ignore when a nonce and 'strict-dynamic' are present, but that keep the policy from breaking on older engines. Modern browsers obey the nonce; old ones fall back to something rather than nothing.

The mistakes that make a CSP useless

  • 'unsafe-inline' on script-src without a nonce. This permits any inline script, which is exactly what an XSS payload is. It reduces your policy to decoration. Our weak-directives check flags it.
  • 'unsafe-eval'. Allows eval() and friends, a classic injection vector. Remove it unless a dependency genuinely needs it, then isolate that dependency.
  • Missing object-src 'none'. Legacy plugin content can bypass script restrictions.
  • Missing base-uri. Without it, injected markup can change the page's base URL and hijack relative script paths.
  • A wildcard * in script-src. Permits scripts from anywhere; equivalent to no policy.

Rolling out without breaking the site

Never deploy an enforcing CSP blind. Use the report-only variant first, which reports violations without blocking anything:

Content-Security-Policy-Report-Only: default-src 'self'; script-src 'nonce-Xy8s2Kd9' 'strict-dynamic'; report-uri /csp-reports
  1. Deploy report-only with your intended policy and a reporting endpoint.
  2. Collect violations from real traffic for a week or two. Each report shows a resource your policy would have blocked.
  3. Fix or allow each legitimate case; investigate anything you do not recognise.
  4. Switch to the enforcing header once the reports go quiet, and keep the reporting endpoint on to catch regressions.
A reporting endpoint is not just for rollout. Left running under an enforcing policy, CSP violation reports are an early-warning system: a sudden spike of blocked inline scripts can be the first sign someone found an injection point.

Two things make rollout smoother than teams expect. First, run report-only and enforcing headers simultaneously during a transition: enforce a policy you are confident in while report-only-testing a stricter one, and you tighten without ever risking a broken page. Second, expect the first wave of violation reports to be dominated by things that are not attacks, browser extensions injecting script, analytics tags, a marketing tag manager. Learn to recognise that noise so it does not stampede you into loosening the policy. The reports you actually care about are the ones that correspond to code you ship, and those are the ones worth allowing precisely.

A realistic policy is a maintained policy

The failure mode of CSP is not usually a bad initial policy; it is a policy that rots. A new third-party widget is added, its script is blocked, and under deadline pressure someone "temporarily" adds unsafe-inline or a broad host to make it work, and the temporary change becomes permanent. Within a year the policy that once neutralised XSS permits almost anything. The antidote is to treat the CSP as code: keep it in version control, review changes to it as carefully as changes to authentication, and re-check it whenever you add a dependency. A policy that is never revisited drifts toward permissiveness, because every individual exception feels reasonable and no one measures the sum.

It also helps to keep the policy generated rather than hand-edited where possible. If your build pipeline knows which scripts a page loads, it can emit the matching nonces and hashes automatically, so the policy tracks the application instead of being maintained beside it. Manual policies fall out of sync; generated ones cannot, which is why the strongest CSP deployments are wired into the framework that renders the page.

CSP is a layer, not the whole wall

A strong CSP dramatically reduces the impact of an injection, but it does not fix the injection. Output encoding, framework auto-escaping and input validation still matter; CSP is the safety net for when one of them fails, as we explain in cross-site scripting explained. It also complements the other headers in HTTP security headers explained, since frame-ancestors handles clickjacking and the rest handle their own attacks.

When you have a policy, test the live header. Our security headers checker grades your CSP and highlights weak directives, and the full website scanner shows it alongside everything else a browser would enforce. A CSP that has never been tested against real traffic is a guess; make it a measurement.

It is worth being honest about the cost, because CSP has a reputation for being hard, and the reputation is half-earned. A truly strict, nonce-based policy on a large application with many third-party integrations takes real engineering: every inline script must carry a nonce, every widget must be accounted for, and the build must be wired to emit the values. That is genuine work. But the payoff is proportionate, CSP is the single most effective mitigation for the web's most damaging client-side attack, and unlike most defences it keeps protecting you against injection points you have not found yet. The pragmatic path is not to demand perfection on day one but to ship a reasonable policy in report-only mode, tighten it over weeks as the reports teach you how your own site loads resources, and treat "strict, nonce-based, no unsafe-inline" as the destination you converge on rather than the gate you must clear before deploying anything. A moderate CSP running today beats a perfect one that is still in a design document.