CSP Academy

← The ten steps · Step 7 of 10 · 5 minutes

How nonces go wrong

A nonce is trivial to implement and easy to implement in a way that does nothing. All four failures below produce a policy that reads perfectly in the header.

1. Reused across responses

The most common one. A nonce minted once per session, per deploy, per boot, or cached in a config somewhere means an attacker fetches any page, reads the value out of the HTML, and puts it on their injected script.

The specification is explicit that a nonce should be used for a single response and not reused. Test it in ten seconds:

curl -sI https://example.com/ | grep -i content-security-policy
curl -sI https://example.com/ | grep -i content-security-policy

Two different values, or you have this bug.

2. Predictable

Nonces derived from the session ID, the URL, a timestamp, a counter, or any of those passed through a hash function are computable by whoever can read your JavaScript. "Looks random" is not the standard; "came from the CSPRNG" is. Use the platform's secure random source, not Math.random(), rand(), uniqid() or mt_rand().

3. Cached

This one catches careful teams, because the application is correct. The origin mints a fresh nonce per response. A CDN, a reverse proxy or a full-page cache stores that HTML and replays it. Every visitor inside the TTL now shares one nonce, and a per-response nonce has quietly become a per-cache-entry nonce.

Any HTML response carrying a nonce needs to be uncacheable:

Cache-Control: no-store

Your CSS, JS, fonts and images carry no nonce and should still be cached as aggressively as you like. If you genuinely need cached HTML, the nonce has to be injected at the edge — after the cache, not before it.

4. Copied by a library

jQuery's .html() parses the string you give it and, for every <script> it finds, creates a real script element and copies across src, type and nonce. Anything reaching that function therefore inherits your page's nonce and executes. Bootstrap's popover with {html: true, sanitize: false} routes user-controlled content into the same path.

No policy change fixes this, because from CSP's point of view nothing went wrong — a trusted script created a script, which is exactly what 'strict-dynamic' permits. The fix is not passing untrusted markup to those APIs.

Two things that are not bugs

'unsafe-inline' alongside a nonce

Browsers ignore 'unsafe-inline' entirely when a nonce or hash is present. Leaving it in as a fallback for ancient browsers is harmless — but it also means a broken nonce will not fail loudly, because the policy still looks like it has a safety net. It does not.

The nonce appearing in the DOM

You will notice the nonce is readable via document.querySelector('script').nonce. That is fine: any script that can run that has already achieved execution, which is the thing the nonce was protecting against. Modern browsers also hide the attribute from CSS attribute selectors and from getAttribute specifically to stop nonce theft through markup injection.

You can do this now

Run the two-curl test above against your own site. If you have a CDN in front of it, run it against the CDN rather than the origin — that is where this bug lives, and the origin will look innocent.