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. 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.

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.

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.

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.

  • 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.


Permalink to "Related"

Related Articles

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