A vendor’s integration guide supplies an inline <script> block, your strict policy blocks it, and the two available remedies — a nonce or a hash — behave very differently depending on how your HTML is cached.

Triage: Establish How the Page Is Served

The choice is decided by one property, so establish it first.

  1. Fetch the page twice with curl -sI and compare the Age and X-Cache response headers. A non-zero Age means a shared cache is serving the body.
  2. Check whether the response carries a Content-Security-Policy header and whether its value differs between the two fetches.
  3. Look at the inline snippet: is its content identical on every response, or does it interpolate a value?
  4. Note who controls the snippet — you, or a tag manager that may rewrite it.
One property decides the choice A decision based on caching. If the HTML body is generated per request, a nonce works and is preferable because it survives changes to the snippet content. If the body is served from a shared cache, a nonce baked into it is a value from a previous response and every nonce-bearing script is blocked, so a content hash is required instead — unless the edge can substitute the nonce per response. body generated per request a fresh nonce per response use a nonce survives content changes edge can substitute cache the body with a placeholder a nonce still works more moving parts body served from cache a baked nonce is stale use a hash and regenerate on every edit Preference does not enter into it. The caching layer decides which mechanism can work at all.

Root Cause: A Nonce Is Per Response, a Hash Is Per Content

A nonce is a random value generated for one response, placed both in the policy header and on the permitted elements. Its security depends entirely on being unpredictable and single-use, which is why a nonce that appears in a cached body is worthless: every visitor receives the same value, an attacker can read it, and — more immediately — it will not match the freshly generated header, so the script is blocked.

A hash identifies the script by its content instead. The policy lists a digest of the exact bytes between the tags, and any element whose content hashes to a listed value is permitted regardless of which response carried it. That makes hashes cache-friendly and content-brittle: change one character of the snippet, including whitespace, and the hash no longer matches.

For third-party snippets the brittleness matters more than usual, because the content is not always yours to control. A vendor that changes its snippet in a documentation update, or a tag manager that rewrites the element on injection, invalidates a hash without touching your codebase — which is the same silent-change problem that subresource integrity addresses for external files, appearing here for inline ones.

What each mechanism binds to A nonce binds a permission to one response: it must be unpredictable, appear in both the header and the element, and never be reused. A hash binds a permission to exact content: it survives caching and any number of responses, but breaks on any change to the bytes including whitespace. Neither can substitute for the other because they answer different questions. nonce → one response unpredictable, single-use, in header and element alike nonce + cached body the value is stale for every visitor — everything is blocked hash → exact content survives caching; breaks on a single changed character A nonce answers "was this element in this response". A hash answers "is this exactly the code I approved".

Resolution: Nonce Where You Can, Hash Where You Must

<!-- Per-response nonce. The same value appears in the header and on the element,
     and is regenerated for every response. Never reuse or cache it. -->
<!-- Content-Security-Policy: script-src 'nonce-r4nd0mV4lu3' 'strict-dynamic' -->
<script nonce="r4nd0mV4lu3">
  window.dataLayer = window.dataLayer || [];
  function gtag(){ dataLayer.push(arguments); }
  gtag('consent', 'default', { ad_storage: 'denied', analytics_storage: 'denied' });
</script>
# Hash for a cached body. Compute over the exact bytes between the tags —
# not including the tags themselves, and including every space and newline.
printf '%s' "$(cat snippet.js)" | openssl dgst -sha256 -binary | openssl base64 -A
# → Content-Security-Policy: script-src 'sha256-…'

Two operational notes make the hash route survivable. Generate the digest in the build rather than by hand, reading the same file that is inlined, so the two cannot diverge — a hash computed once and pasted into a config is a hash that will be wrong after the next edit. And keep the snippet in its own file that the template includes verbatim, so a formatter reflowing your HTML cannot change the bytes without changing the file the hash is computed from.

Where a snippet is small and stable, a third option is worth considering: move it into an external file on your own origin. That removes the inline problem entirely, makes the code reviewable in isolation, and lets 'self' cover it with no nonce or hash at all.

Verification: Block, Then Permit

Deliberately corrupt the nonce or hash by one character and confirm the snippet is blocked with a console violation naming it. Then restore it and confirm the snippet runs. Testing only the passing case cannot distinguish a working policy from one that is not being applied at all.

Test both directions Two checks. With a deliberately corrupted nonce or hash, the snippet must be blocked and a console violation must name it — proving the policy is actually being enforced. With the correct value restored, the snippet must run. A policy that permits in both cases is not being applied, which is a common outcome when the header is set in report-only mode by mistake. corrupted value snippet blocked violation names it proves enforcement correct value snippet runs no violation proves the match runs in both cases the policy is not applied check report-only a very common cause The third column is the outcome people mistake for success.

Snippets You Do Not Control

The awkward case is a snippet supplied by a vendor and injected by a tag manager, where neither the content nor the timing is yours. A hash is impossible because the content can change without your involvement; a nonce is impossible because the injection happens after the response, so nothing can attach the current value to it.

The workable answer is 'strict-dynamic'. A nonce-bearing script of your own is trusted, and anything it creates programmatically inherits that trust — so if the tag manager itself is loaded with a nonce, the snippets it injects are permitted without any per-snippet mechanism. That is a deliberate widening of trust rather than a trick: you are stating that the container is trusted to load what it loads, which is already true in practice since it can load anything.

Be explicit about what that means. Extending trust to a container extends it to every tag anyone with console access can add, which makes the container’s access controls part of your content security posture. That is an argument for auditing who can publish to it, not for avoiding 'strict-dynamic' — a policy that lists origins instead is no better, because the container can load from any of them anyway.

Reducing the Number of Inline Snippets

Before choosing a mechanism, it is worth asking how many inline snippets you genuinely need, because the answer is usually fewer than the number you have. Vendor integration guides default to inline blocks because they are the lowest-friction thing to paste into a documentation page, not because the code requires it — and most of them work identically as an external file on your own origin covered by 'self'.

Moving a snippet out has three secondary benefits beyond the policy question. The code becomes reviewable in isolation rather than embedded in a template, it becomes cacheable rather than re-sent with every response, and it becomes something a formatter cannot silently alter. Each of those removes a way the hash-versus-nonce decision could bite you later.

What genuinely has to stay inline is the small set that must run before anything else and cannot afford a round trip: consent defaults that a container reads on boot, and an anti-flicker snippet whose whole purpose is to execute before the first paint. Those are worth the mechanism; a vendor’s initialisation call that could equally live in your own bundle is not.

Common Pitfalls

  • Caching a nonce. A nonce in a cached body is stale for every visitor after the first, and the failure is total rather than partial.
  • Computing a hash over the wrong bytes. The digest covers the content between the tags, excluding the tags, including all whitespace. A trailing newline changes it.
  • Adding both a nonce and a hash to one element. They are alternatives; listing both does not make the element more permitted and obscures which mechanism is actually in force.
  • Testing only the passing case. A report-only header permits everything, so a snippet that runs proves nothing about enforcement.

Frequently Asked Questions

Can one policy use nonces and hashes together?

Yes, and mixed policies are common in practice. The script-src directive accepts several source expressions, so a policy can list a nonce for the elements your server renders and hashes for a handful of stable snippets it does not. The browser permits an element matching any listed expression.

The caveat is that adding 'strict-dynamic' changes how the rest of the list is interpreted: host expressions are ignored in browsers that support it, though nonces and hashes continue to apply. That is usually what you want, but it means a mixed policy needs testing in browsers on both sides of that support boundary rather than assumed to behave identically.

How random does a nonce need to be?

Cryptographically random and at least 128 bits, generated per response from a secure source. A value derived from the request — a session identifier, a timestamp, a hash of the URL — is predictable to anyone who can observe those inputs, which defeats the mechanism entirely while looking correct in every test you would write.

The other requirement is that it never repeats. Reusing a nonce across responses turns it into a shared secret that leaks with the first cached page, which is the same failure as caching one deliberately. Generate, use once, discard.

What if the vendor changes their snippet?

With a hash, the snippet stops executing until you regenerate it — which is disruptive but at least loud. With a nonce, it continues to run, because the nonce permits the element regardless of content. Neither behaviour is universally right, and the difference is worth deciding deliberately rather than inheriting.

For a snippet that sets consent defaults or performs anything security-relevant, the brittleness of a hash is a feature: an unreviewed change to that code should stop the page rather than proceed. For a cosmetic snippet, it is an outage waiting to happen. Match the mechanism to how much you would want to be interrupted.

Do hashes work for inline event handlers too?

Only with 'unsafe-hashes', which is a separate and weaker source expression covering inline event handler attributes such as onclick. It exists because removing those from a large legacy codebase is genuinely difficult, and it is a migration aid rather than a destination — enabling it re-permits a class of injection that a strict policy was largely adopted to close.

For third-party snippets specifically it is almost never needed. A vendor asking you to add an inline handler is asking for something with an equivalent that works under a strict policy: attach the listener from a script the policy already permits, which costs one line and removes the exception.


Up: Implementing Strict Content Security Policies