Generating Per-Request CSP Nonces

Permalink to "Generating Per-Request CSP Nonces"

Part of CSP Nonces & Hash-Based Policies, this page covers exactly how to produce a fresh, unpredictable nonce on every request and emit it in both the Content-Security-Policy header and the <script nonce="…"> tag — with Express, Nginx, and Cloudflare Worker implementations.

Quick Reference

Permalink to "Quick Reference"
Property Value
Directive syntax script-src 'nonce-<base64>'
HTML attribute nonce="<base64>" on inline <script> / <style>
Entropy (minimum) 128 bits (16 random bytes)
Encoding base64 of the raw bytes (not hex)
Random source CSPRNG only — crypto.randomBytes, crypto.getRandomValues
Uniqueness One value per HTTP response, never reused
Cacheability Nonce-bearing HTML must be no-store in shared caches
Browser support Chrome 40+, Firefox 31+, Safari 15.4+, Edge (all Chromium)

Use a nonce for server-rendered, dynamic HTML. For static or fully cached pages, use source hashes instead — see Migrating from unsafe-inline to Hash-Based CSP.

Why the Nonce Must Be Generated Per Request

Permalink to "Why the Nonce Must Be Generated Per Request"

A CSP nonce is a shared secret between your server and the browser for the lifetime of one response: the server puts an unguessable token in the policy header and stamps the identical token on every inline script it authored. The browser executes an inline script only if its nonce attribute matches the header value. Because a cross-site scripting payload is injected into the response after your template rendered, the attacker never sees the token and cannot forge a matching attribute. That guarantee holds only while the value is unpredictable and single-use — the moment a nonce is reused across responses or derived from something an attacker can observe (a timestamp, a counter, Math.random()), it stops being a secret and the policy becomes decorative.

Two implementation details make or break that secrecy. First, encoding: the CSP grammar accepts a base64-encoded value, so encode the raw 16 random bytes with base64 — never hex, which halves the effective character density, and never a UUID or a slug, which carry far less than 128 bits of real entropy. The alphabet and padding rules are the standard ones, identical to those covered in Base64 Encoding Rules for SRI Hashes, so 16 bytes always render as 24 characters ending in ==. Second, placement in the request lifecycle: generate the nonce as the very first thing your handler does, store it once (for example on res.locals in Express or a request-scoped context object at the edge), and read that single stored value everywhere it is needed. If the header and the template each call the random generator independently, they produce two different tokens and every inline script is refused. One value, generated once, read many times, discarded when the response is sent.

The source expression that carries the token has four distinct parts, and every one of them is a place implementations get wrong: a directive name, the literal 'nonce- keyword, the base64 payload, and the closing quote that the CSP grammar requires but hand-built header strings frequently drop.

Anatomy of a nonce source expression The header value script-src 'nonce-8IBTHwOdqNKAWeKl7plt8g==' is split into four labelled segments: the directive name, the nonce keyword, the base64 payload produced from sixteen random bytes, and the closing quote. A dashed connector shows the same base64 token reappearing verbatim inside the script tag's nonce attribute. Where the 128 bits live in the source expression script-src 'nonce- 8IBTHwOdqNKAWeKl7plt8g== ' directive name nonce keyword base64 of 16 CSPRNG bytes closing quote same token <script nonce="8IBTHwOdqNKAWeKl7plt8g=="> Header and markup must carry byte-identical tokens

Canonical Implementation

Permalink to "Canonical Implementation"

Generate 16 bytes from the platform CSPRNG, base64-encode them, and thread that one value into both the header and the markup. This Express example is the reference pattern; the value is computed once per request and never cached.

import express from 'express';
import { randomBytes } from 'node:crypto';

const app = express();

app.use((req, res, next) => {
  // 16 bytes = 128 bits of entropy, base64-encoded → the nonce token
  const nonce = randomBytes(16).toString('base64');
  res.locals.cspNonce = nonce;

  res.setHeader(
    'Content-Security-Policy',
    [
      `script-src 'nonce-${nonce}' 'strict-dynamic' https:`,
      `object-src 'none'`,
      `base-uri 'none'`,
    ].join('; ')
  );

  // Never let a shared cache store a response that carries a nonce
  res.setHeader('Cache-Control', 'no-store');
  next();
});

app.get('/', (req, res) => {
  const nonce = res.locals.cspNonce;
  res.send(`<!doctype html>
<html>
  <head><title>Nonce demo</title></head>
  <body>
    <script nonce="${nonce}">
      window.__APP__ = { ready: true };
    </script>
    <script nonce="${nonce}" src="/static/app.entry.js"></script>
  </body>
</html>`);
});

app.listen(3000);

The single nonce constant is used three times — once in the header, once on the inline block, once on the loader — guaranteeing the header and markup can never drift apart. Read as an ordered sequence, the middleware performs exactly four writes per request: it pulls bytes from the CSPRNG, writes the policy header, stamps the attribute on every script the template renders, and marks the response uncacheable. Nothing in that sequence crosses a request boundary, which is what keeps the token secret.

Order of writes inside one request A sequence diagram with three lifelines — CSPRNG, request handler and HTTP response. The handler receives sixteen random bytes, writes the Content-Security-Policy header, writes the nonce attribute onto each inline script, and finally sets Cache-Control no-store on itself, after which header and markup carry the same token and the authored scripts execute. CSPRNG Request handler HTTP response 1. 16 random bytes (128 bits) 2. Content-Security-Policy header 3. nonce attribute on each script 4. Cache-Control: no-store Same token in header and markup, so the authored scripts execute

Variant Examples

Permalink to "Variant Examples"

Nginx with the ssl_reject_handshake-free sub_filter approach

Permalink to "Nginx with the ssl_reject_handshake-free sub_filter approach"

Nginx generates a request-scoped variable with the set_secure_random_alphanum directive from ngx_http_set_misc (OpenResty) or a js_set handler in njs, then substitutes it into both the header and the body:

# nginx.conf — using njs to compute one nonce per request
js_import csp from conf.d/csp.js;
js_set $csp_nonce csp.nonce;

server {
  location / {
    add_header Content-Security-Policy
      "script-src 'nonce-$csp_nonce' 'strict-dynamic' https:; object-src 'none'; base-uri 'none'" always;
    add_header Cache-Control "no-store" always;

    sub_filter_once off;
    sub_filter '__CSP_NONCE__' $csp_nonce;   # template placeholder in the HTML
    proxy_pass http://app_upstream;
  }
}
// conf.d/csp.js — njs module
import crypto from 'crypto';
function nonce(r) {
  return crypto.randomBytes(16).toString('base64');
}
export default { nonce };

The upstream application emits <script nonce="__CSP_NONCE__">, and Nginx rewrites the placeholder in the body with the same value it injected into the header.

Cloudflare Worker (edge injection)

Permalink to "Cloudflare Worker (edge injection)"

At the edge, generate the nonce with the Web Crypto API and use HTMLRewriter to stamp it onto script tags while writing the header:

export default {
  async fetch(request, env) {
    const raw = crypto.getRandomValues(new Uint8Array(16));
    const nonce = btoa(String.fromCharCode(...raw));

    const response = await fetch(request);
    const rewritten = new HTMLRewriter()
      .on('script[data-nonce]', {
        element(el) { el.setAttribute('nonce', nonce); },
      })
      .transform(response);

    const headers = new Headers(rewritten.headers);
    headers.set(
      'Content-Security-Policy',
      `script-src 'nonce-${nonce}' 'strict-dynamic' https:; object-src 'none'; base-uri 'none'`
    );
    headers.set('Cache-Control', 'no-store');

    return new Response(rewritten.body, { ...rewritten, headers });
  },
};

The origin marks the tags it wants nonced with data-nonce and the Worker fills in the actual value, so the origin HTML can stay cacheable while the header and injected nonce are always fresh. That last property is the reason edge injection is worth the extra moving part, and it composes with the streaming rewrites described in SRI with Cloudflare and Fastly Edge Transforms — the same pass that stamps a nonce can add or verify an integrity attribute.

The three variants differ in exactly one architectural decision: which tier owns the request-scoped random value, and therefore which tier is forced to give up caching.

Nonce implementations compared A four-row comparison matrix. For Express middleware, Nginx with njs, and a Cloudflare Worker it lists where the nonce is generated, how it reaches the markup, whether the origin HTML stays cacheable, and which API writes the header. Only the edge Worker keeps origin HTML cacheable, because it injects the nonce after the cache. Variant Express middleware Nginx + njs Cloudflare Worker Nonce generated in app middleware js_set handler edge fetch handler Reaches markup via template literal sub_filter rewrite HTMLRewriter Origin HTML cacheable No, needs no-store No, needs no-store Yes, stamped after Header written by res.setHeader add_header always Headers.set

Gotchas and Edge Cases

Permalink to "Gotchas and Edge Cases"
  • Nonce reuse across requests silently disables the control. If you compute the nonce once at boot, store it in a module-level constant, or reuse a session value, every visitor gets a stable token an attacker can copy from any page. Generate inside the request handler, every time. This is the single most damaging mistake because the page keeps working, so the regression is invisible until a pentest finds it.

  • Caching a page with a nonce poisons it for everyone. A shared CDN or reverse-proxy cache that stores a nonce-bearing HTML body will replay one visitor’s nonce to thousands of others, while the enforcing header (if generated fresh) no longer matches — producing mass Refused to execute inline script errors. Always set Cache-Control: no-store on nonce responses, or move static routes to hashes.

  • Forgetting the nonce on dynamically injected scripts. A script your app creates at runtime needs the nonce set before insertion, and if it is a cross-origin file it also needs integrity and crossorigin for byte-level verification. Set el.nonce (not just setAttribute) so the value is not exposed via the DOM attribute reflection that some frameworks strip:

    const s = document.createElement('script');
    s.src = 'https://cdn.example.com/widget.min.js';
    s.integrity = 'sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC';
    s.crossOrigin = 'anonymous';           // required, or SRI silently skips
    s.nonce = document.currentScript.nonce; // inherit the page nonce
    document.head.appendChild(s);
  • Omitting crossorigin on an SRI-tagged injected script. When the injected tag above carries an integrity attribute but no crossorigin="anonymous", the browser makes a no-CORS request, receives an opaque response it cannot read, and blocks the resource — not because the hash failed, but because the bytes were never available for comparison. Under a 'strict-dynamic' nonce policy this looks like a mysterious load failure. Always pair integrity with crossorigin; the request-mode rules behind that are set out in How CORS and crossorigin Affect SRI.

  • Math.random() is not a CSPRNG. It is seeded predictably and its output can be reconstructed. Use crypto.randomBytes (Node), crypto.getRandomValues (browser/edge), or read /dev/urandom. A nonce from a weak generator provides zero protection.

  • Templating engines can strip or double-encode the attribute. Some frameworks HTML-escape attribute values, which turns the +, /, and = characters of a base64 nonce into entities the browser will not match. Others reflect a nonce DOM property but strip the visible attribute for security. Set the property directly (element.nonce) for injected scripts, and verify the served source shows the raw base64 token, not nonce="8IBTHw&#x2B;...".

  • A single nonce covers only the directive it appears in. A 'nonce-…' on script-src does not authorize inline <style>. If your page emits inline styles, add a matching 'nonce-<value>' to style-src using the same per-request value, or the styles are blocked once unsafe-inline is removed from style-src.

Verification Steps

Permalink to "Verification Steps"

1. Confirm the header and markup carry the same value

Permalink to "1. Confirm the header and markup carry the same value"

Fetch the page and diff the header nonce against the body nonce:

curl -sD - https://your-site.example/ -o body.html | grep -i content-security-policy
grep -o 'nonce="[^"]*"' body.html | head -1

The token in the Content-Security-Policy header must equal the token in the nonce="…" attribute character-for-character.

2. Confirm the value changes every request

Permalink to "2. Confirm the value changes every request"
for i in 1 2 3; do
  curl -sD - https://your-site.example/ -o /dev/null | grep -oi "nonce-[A-Za-z0-9+/=]*"
done

Expected output — three different nonce-… tokens. Identical tokens mean the nonce is cached or reused, which is a defect.

3. Confirm enforcement in DevTools

Permalink to "3. Confirm enforcement in DevTools"

Open the console and try to inject an unnonced script:

document.body.appendChild(Object.assign(document.createElement('script'), { textContent: 'window.x=1' }));

Expected result — the browser refuses execution with Refused to execute inline script because it violates the following Content Security Policy directive: "script-src 'nonce-…'", and window.x stays undefined.

Frequently Asked Questions

Permalink to "Frequently Asked Questions"
How many bits should a CSP nonce have?

At least 128 bits of entropy, per the CSP Level 3 specification. Sixteen bytes from a cryptographically secure random number generator, base64-encoded, satisfies this and produces a roughly 24-character token.

Can I reuse one nonce for the whole session?

No. The nonce must be unique per response. Reusing a value across requests or across a session makes it predictable to an attacker who has seen one page, which lets an injected script carry a matching nonce and defeat the policy.

Does a nonce remove the need for SRI on third-party scripts?

No. A nonce authorizes a tag; it says nothing about the bytes that tag fetches. If a CDN serves a modified file, the nonced tag still loads it. The nonce answers “did my server author this tag”, while an integrity attribute answers “are these the bytes I approved”. Production policies carry both, and a nonced external script still needs crossorigin="anonymous" alongside its integrity value.

Why do nonced pages break once a CDN is in front of them?

Because the CDN stored the HTML body, including its nonce, and now replays it to every visitor while the origin issues a fresh header. The header token and the cached attribute token no longer agree, so every inline script is refused. Send Cache-Control: no-store on nonce-bearing HTML, or inject the nonce at the edge so header and body are rewritten together.

Should the nonce be regenerated for each response or each navigation?

Each response. Every HTML document your server emits gets its own value, including redirects that carry a body, error pages, and fragments returned to a client-side router that get injected as markup. A single-page application that fetches HTML partials must either strip inline scripts from those partials or have the server stamp the current document’s nonce onto them.


Permalink to "Related"

Related Articles

Migrating from unsafe-inline to Hash-Based CSP
CSP Nonces & Hash-Based Policies Runtime Policy Enforcement & T…