If you’re choosing between CSP hashes and nonces, the short version is this:

  • Use nonces when your page is rendered dynamically and you control the HTML response.
  • Use hashes when the inline code is static and rarely changes.
  • If you’re dealing with modern third-party loaders, nonce + strict-dynamic is usually the cleanest option.

I’ve seen teams overcomplicate this. CSP gets much easier once you stop treating hashes and nonces as interchangeable. They solve similar problems, but they fit very different delivery models.

The problem both solve

CSP blocks inline JavaScript by default when you use a restrictive script-src. That’s good, because inline script is one of the easiest XSS execution paths.

These are blocked under a strict policy:

<script>
  window.appConfig = { apiBase: "/api" };
</script>

<button onclick="doSomething()">Click</button>

To allow inline <script> safely, you generally choose one of these:

  • Nonce-based allowlisting
  • Hash-based allowlisting

They both let specific inline scripts run without falling back to 'unsafe-inline'.

Nonce-based CSP

A nonce is a random per-response token. You generate it on the server, add it to the CSP header, and also attach it to trusted <script> tags.

Example header

Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-r4nd0mBase64Value'; object-src 'none'; base-uri 'self';

Matching script tag

<script nonce="r4nd0mBase64Value">
  window.appConfig = {
    apiBase: "/api",
    env: "production"
  };
</script>

If the nonce in the tag matches the nonce in the response header, the browser runs the script.

Why I like nonces

Nonces are great when:

  • HTML is generated dynamically
  • inline bootstrap code changes often
  • you need to pass server-side data into the page
  • you use a trusted script loader and want strict-dynamic

This is the real advantage: you don’t need to recalculate a hash every time the inline content changes.

Express example

import crypto from "node:crypto";
import express from "express";

const app = express();

app.get("/", (req, res) => {
  const nonce = crypto.randomBytes(16).toString("base64");

  res.setHeader(
    "Content-Security-Policy",
    [
      "default-src 'self'",
      `script-src 'self' 'nonce-${nonce}'`,
      "object-src 'none'",
      "base-uri 'self'"
    ].join("; ")
  );

  res.send(`
    <!doctype html>
    <html>
      <head><title>Nonce CSP</title></head>
      <body>
        <script nonce="${nonce}">
          window.appConfig = { apiBase: "/api", userId: 123 };
        </script>
        <script nonce="${nonce}" src="/app.js"></script>
      </body>
    </html>
  `);
});

app.listen(3000);

Nonce rules that people get wrong

  1. Generate a fresh nonce for every response Not per deploy. Not per process start. Per response.

  2. Use cryptographically strong randomness Don’t invent your own token format.

  3. Don’t expose the nonce in places attackers can easily reuse If you have an XSS that can read DOM attributes, a nonce is not magic. CSP reduces risk; it doesn’t erase insecure rendering.

  4. Nonce only works for elements that support it Mostly <script> and <style>.

Hash-based CSP

A hash is a digest of the exact inline script content. Instead of saying “scripts with this token may run,” you say “scripts with exactly this content may run.”

Example header

Content-Security-Policy: default-src 'self'; script-src 'self' 'sha256-2v0m0T4V8z1VwZ5m1YqY0j7YH7WQ9l8m4uH0F8xVYkQ='; object-src 'none'; base-uri 'self';

Matching script

<script>
  console.log("hello from inline script");
</script>

The hash must be computed from the exact contents inside the <script> tag. Whitespace counts. Newlines count. A tiny formatting change breaks it.

Generate a SHA-256 hash

Node.js:

import crypto from "node:crypto";

const script = 'console.log("hello from inline script");';
const hash = crypto.createHash("sha256").update(script, "utf8").digest("base64");

console.log(`'sha256-${hash}'`);

OpenSSL:

printf 'console.log("hello from inline script");' \
  | openssl dgst -sha256 -binary \
  | openssl base64

Then put the result in your policy:

Content-Security-Policy: script-src 'self' 'sha256-...';

Why hashes are useful

Hashes are great when:

  • inline code is static
  • pages are cached heavily
  • content is built once and served many times
  • you want deterministic policies without server-side nonce generation

This is common for static sites, docs sites, and pages with one tiny bootstrap snippet.

Side-by-side comparison

Nonce-based

Pros

  • Easy for dynamic inline code
  • Works well with SSR apps
  • Pairs nicely with strict-dynamic
  • No need to recalculate hashes when content changes

Cons

  • Requires server-side response customization
  • Makes full-page caching trickier unless your edge injects the nonce
  • You must plumb the nonce into templates correctly

Hash-based

Pros

  • Great for static HTML
  • Cache-friendly
  • No per-request token generation
  • Very explicit allowlist

Cons

  • Fragile: whitespace or formatting changes break it
  • Painful if inline content changes often
  • Annoying in templates that embed dynamic values

My rule of thumb: if your inline script contains request-specific data, hashes are the wrong tool.

Real-world nonce example with strict-dynamic

Here’s the real CSP header from headertest.com:

content-security-policy: default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com; script-src 'self' 'nonce-Y2QzMjI4ZjAtZGE1OC00M2FhLWExZmQtNWQ4ODA1NzlhNzZl' 'strict-dynamic' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com; style-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://*.cookiebot.com https://consent.cookiebot.com; img-src 'self' data: https:; font-src 'self'; connect-src 'self' https://api.headertest.com https://tallycdn.com https://or.headertest.com wss://or.headertest.com https://*.google-analytics.com https://*.googletagmanager.com https://*.cookiebot.com; frame-src 'self' https://consentcdn.cookiebot.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'

The interesting part is this:

script-src 'self' 'nonce-Y2QzMjI4ZjAtZGE1OC00M2FhLWExZmQtNWQ4ODA1NzlhNzZl' 'strict-dynamic' ...

That tells me they trust a nonce-bearing bootstrap script, and then allow scripts loaded by that trusted script. That’s usually a better fit for tag managers than trying to maintain a giant host allowlist forever.

A minimal version looks like this:

Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{{NONCE}}' 'strict-dynamic'; object-src 'none'; base-uri 'self';

HTML:

<script nonce="{{NONCE}}" src="/bootstrap.js"></script>

And /bootstrap.js can dynamically load other scripts:

const s = document.createElement("script");
s.src = "https://www.googletagmanager.com/gtm.js?id=GTM-XXXXXXX";
document.head.appendChild(s);

For modern browsers, strict-dynamic means the trust flows from the nonce-approved script. That’s powerful, and honestly one of the best reasons to prefer nonces in app-style deployments.

Copy-paste policies

1. Static site with one fixed inline script: use a hash

Content-Security-Policy: default-src 'self'; script-src 'self' 'sha256-REPLACE_WITH_HASH'; object-src 'none'; base-uri 'self'; frame-ancestors 'none';

Inline script:

<script>
  document.documentElement.classList.add("js");
</script>

2. Server-rendered app with dynamic config: use a nonce

Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{{NONCE}}'; object-src 'none'; base-uri 'self'; frame-ancestors 'none';

HTML:

<script nonce="{{NONCE}}">
  window.__CONFIG__ = {
    csrfToken: "{{csrfToken}}",
    apiBase: "/api"
  };
</script>

3. Nonce + strict-dynamic for trusted loaders

Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{{NONCE}}' 'strict-dynamic'; object-src 'none'; base-uri 'self';

HTML:

<script nonce="{{NONCE}}" src="/loader.js"></script>

For more ready-to-use policy patterns, you can also browse https://csp-examples.com.

Common mistakes

Using 'unsafe-inline' with hashes or nonces

That usually defeats the point for scripts. If you’re trying to harden CSP, remove it from script-src.

Hashing the wrong content

You hash only the text inside the <script> tag, not the tag itself.

Forgetting that templating changes hashes

This breaks constantly:

<script>
  window.userId = "{{ user.id }}";
</script>

If user.id changes, the hash changes. That’s a nonce use case.

Reusing one nonce everywhere forever

That turns a nonce into a static secret, which is pointless.

Which one should you choose?

Pick hashes if all of this is true:

  • your HTML is mostly static
  • inline code rarely changes
  • you want easy CDN caching
  • you don’t want per-request CSP generation

Pick nonces if any of this is true:

  • you render HTML dynamically
  • inline script contains runtime data
  • you use SSR frameworks
  • you want strict-dynamic
  • you load third-party scripts through a trusted bootstrap script

If I had to give one opinionated default for most modern web apps: use nonces. Hashes are excellent, but mostly for static content and very small controlled snippets. In app backends, nonces fit the workflow better and scale with fewer weird edge cases.

If you want the browser’s exact behavior and directive details, the official reference is the MDN CSP documentation: https://developer.mozilla.org/docs/Web/HTTP/CSP.