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 scripterrors. Always setCache-Control: no-storeon 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
integrityandcrossoriginfor byte-level verification. Setel.nonce(not justsetAttribute) 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
crossoriginon an SRI-tagged injected script. When the injected tag above carries anintegrityattribute but nocrossorigin="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 pairintegritywithcrossorigin. -
Math.random()is not a CSPRNG. It is seeded predictably and its output can be reconstructed. Usecrypto.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 anonceDOM 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, notnonce="8IBTHw+...". -
A single nonce covers only the directive it appears in. A
'nonce-…'onscript-srcdoes not authorize inline<style>. If your page emits inline styles, add a matching'nonce-<value>'tostyle-srcusing the same per-request value, or the styles are blocked onceunsafe-inlineis removed fromstyle-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.
Related
Permalink to "Related"- CSP Nonces & Hash-Based Policies — parent workflow covering nonce injection,
'strict-dynamic', enforcement, and removingunsafe-inline - Migrating from unsafe-inline to Hash-Based CSP — the hash-based alternative for static, cacheable pages where a per-request nonce is impossible
- Configuring Content Security Policy with SRI — combining nonce and
require-sri-fordirectives so injected external scripts are both authorized and byte-verified