Back to Blog
Flow diagram linking client request, server generation, build process, CDN caching and secure rendering
Security
Jun 1, 2026
10 Min Read

A CSP Nonce Is Never Just a Header

The ticket seemed simple. A security scan flagged 'unsafe-inline' in the Content Security Policy, and a reviewer suggested using a nonce. It was added to the sprint with a two-hour estimate. That made sense, since swapping a keyword for a token in a header is just a header change.

Then you start.

The nonce is a contract between two places

Here’s what the control actually asks for, on every single HTML response you send:

  • a cryptographically strong, unpredictable value (usually at least 128 bits) that can be included in the policy
  • that value written into script-src (and style-src when you need it) as 'nonce-{value}'
  • the identical value on the nonce attribute of every trusted <script> or <style> element

The browser then carries out only that which matches; you then discard the value and produce a new one for the next response.

CSP header nonce matching one script tag while an unnonced script and a stale-nonce script are blocked

A timestamp isn’t useful either, nor is a counter, an app-start constant, or any value derived from the session (since anything that is predictable remains predictable regardless of how clever the derivation may be). It’s perfectly fine to use the same nonce across all the approved scripts in a single document. Just be sure to avoid having a stable value carry over between responses that are sent independently.

A point that is often overlooked is that the nonce should only be applied to markup that you already trust. If you apply the nonce to everything in the document after untrusted content has been inserted there, you’ll be effectively authorizing the attacker’s script just as if it were your own. It’s up to your implementation to ensure that the output from the template and the injected content can be told apart. That responsibility falls under application logic, not on a header.

Static HTML and fresh nonces want opposite things

The same bytes are sent to all users when it comes to static files, but a nonce must be different each time; it’s impossible for both of these to be true unless the response is altered after the static file has been retrieved.

That’s where the two hours disappear.

Next.js makes these costs more obvious. Its nonce requirements mean you have to use dynamic rendering, which disables static optimization and ISR for those pages, makes Partial Prerendering incompatible, and removes responses from default CDN caching unless you work to restore it. This is specific to the framework, not a web standard. It clearly shows how a security decision can affect the entire rendering model.

Next.js panel showing a nonce requires dynamic rendering and disables ISR, PPR and CDN caching

Four architectures, each billing a different team

Generate the HTML in response to each request. Produce the nonce during the request lifecycle, pass it through the renderer, and send out both the header and the markup at the same time. This approach is easy to understand. The drawback is that it uses up request-time computing power and depends on the origin.

Substitute at the edge. Keep a cached original template. Then, the edge runtime generates a nonce and rewrites both the CSP header and trusted elements for each delivery. Akamai describes this approach in detail. You keep most of your cache, but now the delivery layer is responsible for security-critical HTML rewriting. How does it pick only the trusted elements? Does the rewriting work with streaming and compression? Are the header and body always updated together? What happens with redirects, 500 errors, or bypass routes?

Go with hashes. Hash the precise inline script content, include these hashes in the policy, and keep the page static; hashes will only change when the script itself changes. The rule suggested by web.dev is a reasonable one: use nonces with server-rendered HTML and hashes with static or cacheable HTML. The cost is then shifted into the build and release processes, however. Edits that involve whitespace will break the hashes, and it can be difficult to consistently list framework-generated scripts, since every approved script block has to be maintained.

Remove the inline code by combining scripts into bundles, converting onclick attributes into addEventListener calls, and turning style attributes into classes. This is the cleanest policy you’ll ever be able to implement and might well be the biggest migration effort since old templates, tag managers, third-party widgets, and CSS-in-JS output all resist the changes.

Four CSP nonce architectures compared by which layer absorbs the cost

You needn’t choose one, since personalized app routes can be rendered dynamically while the marketing pages make use of hashes, treating each route as a valid solution.

Caching survives - as long as the nonce comes last

People often say “Nonces kill your CDN,” but that’s not quite right. The real issue is that a shared cache can’t resend a finished response with a nonce as if the nonce were still new. That’s the main problem. Immutable JavaScript, CSS, images, fonts, cached query results, and reusable fragments still cache normally. The key is timing: generation and substitution must happen after the reusable item, not before.

Two delivery paths: a cached nonce replayed to every user versus a fresh nonce injected at the edge

In real-world situations this mistake is made on a large scale. A study involving 2,271 sites that used nonces found that 598 of them reused a nonce in more than one response, some of these cases being due to server behaviour and others to web caches, with 318 sites noted for actually caching the nonce. No warning lights come on when this occurs. The policy still appears to be correct when it is reviewed; the guarantee is just no longer there.

Inline handlers and style attributes are a second migration

A nonce grants the permission for something to have the attribute: the <script> and <style> elements. All of this remains exactly as unauthorised as it originally was:

  • onclick, onload, and the other inline event handlers
  • style="..." attributes
  • javascript: URLs
  • eval(), new Function(), and similar string-to-code execution

Nonces cover script and style elements but not inline handlers, style attributes, javascript URLs or eval

MDN sets this out clearly: using nonces or hashes for script elements doesn’t get rid of any of the points raised above; those items still need to be removed or a separate decision has to be made. CSP Level 3 does provide the 'unsafe-hashes' option, which allows a matching hash to authorize event handlers, style attributes, and javascript: navigation targets. This is useful for maintaining compatibility. However, don’t confuse it with the refactoring: a hash authorizes that code string anywhere it appears, which is a more permissive kind of permission than actually fixing the individual element.

That is to say, when we remove 'unsafe-inline' it usually applies only to script and style elements. It’s worth saying out loud before anyone declares the migration complete.

Don’t claim a slowdown you haven’t measured

Changing from a shared static response to one that is generated on a per-request basis can increase server workload and lead to higher latency. The extent of this effect will vary according to the framework, the cache hierarchy, the nature of the traffic, the runtime, and the mix of routes. It is pointless to try and estimate the figure since that is precisely what causes a proposal to be rejected.

Take a before/after baseline on:

  • HTML cache-hit ratio at every shared-cache layer
  • origin requests per second and request amplification
  • time to first byte at p50, p95, p99
  • p75 Largest Contentful Paint and Interaction to Next Paint
  • render duration, CPU time, memory, concurrency, cold starts
  • bandwidth and compute cost per 1,000 page views
  • error rate, timeout rate, availability when the origin or edge stumbles
  • CSP violation counts by directive, route, browser, and release

Separate anonymous and authenticated traffic, since they usually behave differently. The change might barely matter for account pages that already render dynamically, but it can be costly for high-traffic static pages.

Ship in report-only, then prove the invariant

Content-Security-Policy-Report-Only shows violations without blocking the page. That’s its purpose and also its limit, since it doesn’t enforce anything. First, run all your traffic through it: test uncommon routes, third-party flows, error paths, authentication, different browsers, and mobile clients. Then fix the real violations instead of just making the policy broader. Every new host, hash, or exception is a trust decision that someone should review.

Report-only rollout loop: run traffic, remove violations rather than broadening policy, then enforce

Before you enforce, verify the invariant rather than the header:

  • two independent responses carry different nonce values
  • inside one response, the header value and every intended element value match
  • a script without the nonce is blocked
  • a nonce copied from an earlier response gets rejected by the new one
  • inline handlers and javascript: URLs are blocked once 'unsafe-inline' is gone
  • third-party and dynamically inserted scripts work only through paths you intended
  • cache headers and observed cache status match the architecture you chose
  • 404s, 500s, redirects, and maintenance pages receive a deliberate policy

A scanner can confirm a header exists and a weak token disappeared. It can’t tell you every route gets the intended policy, every nonce is unpredictable and single-use, every trusted script has earned the trust, or every cache layer preserves the design.

Installing a control isn’t the same as keeping it

Sometimes this really is configuration. The app already renders each response dynamically, the markup carries no problematic attributes, the framework threads a nonce through its generated scripts. Take the win. Caching behavior and failure paths still want checking.

And keep the ceiling in view: CSP is defense in depth. It shrinks what an injection flaw can accomplish by refusing to run code that lacks an approved nonce or hash. What it won’t do is encode output, escape by context, sanitize input, secure your dependencies, or fix the bug that let the markup in. Treat it as your only XSS defense and a mitigation layer quietly becomes a single point of failure.

The lesson goes beyond just using a nonce. A security control becomes an architecture decision as soon as its guarantee depends on where, when, and how each response is rendered. Installing it is the easy part. Keeping the invariant across build, cache, edge, and error paths is the real challenge, and that’s what the estimate missed.

Filed Under

Join the Conversation

This dispatch is part of an ongoing series on the future of intelligence. Share your perspective or subscribe for more.

Weekly dispatches. No spam. Ever.