Skip to content
C Codeloom
Web

Content Security Policy Explained

A practical tour of CSP: what each directive does, how nonces and hashes work, how to roll out a policy safely with report-only mode, and the mistakes that quietly weaken it.

·4 min read · By Codeloom
Intermediate 10 min read

What you'll learn

  • What CSP is and what it actually blocks
  • The directives that matter most
  • Nonces vs hashes vs allowlists
  • Rolling out CSP without breaking the site
  • Common ways policies become useless

Prerequisites

  • Basic web frontend and HTTP header knowledge

Cross-site scripting is still the easiest way to compromise a web app. Content Security Policy is the browser-enforced second line of defense: even if an attacker injects a script tag, the browser refuses to run it. The catch is that a sloppy policy gives you the warm feeling of security without the security.

What and Why

CSP is an HTTP response header that tells the browser which sources of content are allowed for a page. Scripts, styles, images, fonts, frames, connections, and more each get a directive. Anything not allowed is blocked and, optionally, reported. The policy is enforced by the browser, so it cannot be turned off by attacker-controlled markup.

The point is not to replace input sanitization or output encoding. It is to contain the blast radius when those fail. A strict CSP turns a stored XSS from “attacker runs JavaScript as your user” into “attacker injects HTML that the browser refuses to execute.”

Mental Model

Think of CSP as a guest list at the door. The page is the venue. Every resource that wants in shows the bouncer where it came from. Inline scripts have no origin, so they need a special pass, either a one-time nonce or a fingerprint hash. Anything without a pass and not on the list gets turned away.

The directives form a tree. default-src is the fallback for most fetch types; script-src, style-src, img-src, connect-src, frame-src, and a few others override it for their type. object-src 'none', base-uri 'self', and frame-ancestors 'none' are the cheap wins almost every site should set.

Hands-on Example

A reasonable strict policy for a server-rendered app:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-r4nd0m1234';
  style-src  'self' 'nonce-r4nd0m1234';
  img-src    'self' data: https://cdn.example.com;
  connect-src 'self' https://api.example.com;
  frame-ancestors 'none';
  base-uri 'self';
  object-src 'none';
  report-to csp-endpoint

The server generates a fresh nonce per response and injects it into legitimate tags:

<script nonce="r4nd0m1234">window.__INIT__ = { user: "ada" };</script>

An attacker who injects a <script>...</script> cannot guess the nonce, so the browser blocks it.

HTML parser sees <script>
      |
      v
inline?    --no--> src origin in script-src list? -- yes --> run
      |                         |
     yes                       no
      |                         v
has nonce/hash               blocked + report
matching policy?
      |       \
     yes      no
      |        \
     run       blocked + report
How CSP evaluates a script tag

Start in report-only mode so violations get reported but nothing is blocked.

Content-Security-Policy-Report-Only:
  default-src 'self'; script-src 'self' 'nonce-...'; report-to csp-endpoint

Collect reports for a week, fix the legitimate violations, then flip to the enforcing header.

Common Pitfalls

  • unsafe-inline in script-src. This single keyword neutralizes most of CSP’s XSS protection. If you cannot move to nonces yet, at least pair it with 'strict-dynamic' and a nonce.
  • Wildcards on CDNs. script-src https: or *.cloudfront.net lets attackers host scripts on the same wildcard. Pin specific hosts.
  • Allowing data: in script-src. That permits data:text/javascript,... payloads. Never do this.
  • Forgetting base-uri. Without it, an injected <base> tag can redirect every relative URL to an attacker domain.
  • Not setting frame-ancestors. Without it, anyone can iframe your app and try clickjacking. frame-ancestors 'none' or 'self' shuts that down and replaces the old X-Frame-Options.

Practical Tips

Use nonces over allowlists when you can; they survive code changes and CDN swaps. For fully static sites, hashes work well because content does not change. Add upgrade-insecure-requests to nudge mixed content to HTTPS. Send reports to an endpoint you actually watch; an unread report is just noise. Test the policy in your real preview environments because third-party scripts (analytics, support widgets, A/B tools) all have opinions, and you will discover them at the worst possible moment.

CSP works best alongside other headers: Strict-Transport-Security, Referrer-Policy, Permissions-Policy, and Cross-Origin-Opener-Policy. None of them replace each other.

Wrap-up

CSP turns “we hope our sanitizer is perfect” into “the browser will refuse to run anything we did not vouch for.” Get there with nonces, no inline escapes, a tight frame-ancestors, and a report-only rollout. The policy will look long, and that is fine; a long, specific allowlist beats a short, vague one every time.