CSP nonce: script-src 'nonce-...' (tested)
A CSP nonce is a random value you put in the header (script-src 'nonce-R4nd0m') and on each <script nonce="R4nd0m"> tag you trust. The browser runs only the scripts whose nonce matches. An attacker who injects HTML cannot guess it, so their script is blocked. It only works if the nonce is new on every response. We tested each rule below in Chromium 153 and wired it up in Express 5 with helmet. For the full policy, see our Content-Security-Policy header example.
1. What Chromium 153 did (measured)
Each page was served by Express on localhost with the header shown. We read the console in headless Chromium 153.
| Policy | Script | Result |
|---|---|---|
script-src 'nonce-abc' | <script nonce="abc"> inline | Ran |
script-src 'nonce-abc' | <script nonce="wrong"> inline | Blocked |
script-src 'nonce-abc' | <script> with no nonce | Blocked |
script-src 'nonce-abc' 'unsafe-inline' | <script> with no nonce | Blocked ('unsafe-inline' ignored) |
script-src 'unsafe-inline' (no nonce) | <script> with no nonce | Ran |
script-src 'nonce-abc' | <button onclick="..."> | Blocked on click |
script-src 'nonce-abc' | nonced script adds createElement('script') with src=/ext.js | Blocked |
script-src 'nonce-abc' 'strict-dynamic' | same as above | Loaded and ran |
script-src 'nonce-abc' 'strict-dynamic' | <script src="/ext.js"> in the HTML, no nonce | Blocked |
The console error for a missing or wrong nonce, word for word:
Executing inline script violates the following Content Security Policy directive 'script-src 'nonce-abc123fixed''. Either the 'unsafe-inline' keyword, a hash ('sha256-...'), or a nonce ('nonce-...') is required to enable inline execution. The action has been blocked.
With 'unsafe-inline' next to the nonce, Chromium added: Note that 'unsafe-inline' is ignored if either a hash or nonce value is present in the source list.
For a blocked onclick the message starts with Executing inline event handler violates ....
2. Add a per-request nonce in Express 5 with helmet (measured)
const express = require('express');
const helmet = require('helmet');
const crypto = require('crypto');
const app = express();
app.use((req, res, next) => {
res.locals.nonce = crypto.randomBytes(16).toString('base64');
next();
});
app.use(helmet({
contentSecurityPolicy: {
directives: {
scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.nonce}'`],
},
},
}));
app.get('/', (req, res) => {
res.send(`<script nonce="${res.locals.nonce}">console.log('ok')</script>`);
});
With Express 5.2.1 and helmet 8.3.0, two curl -I requests got two different nonces, for example:
script-src 'self' 'nonce-hZHIvPuqvmEHq91qIlUg0A=='
script-src 'self' 'nonce-UDI8y0TJJhHXuk9IjQS6cQ=='
In Chromium the nonced inline script ran and a plain <script> on the same page was blocked. Helmet also sent its other defaults, including script-src-attr 'none', which blocks onclick-style handlers too.
If you use a template engine, pass res.locals.nonce into every <script> tag you own. 16 random bytes is 128 bits; the spec asks for at least 128 bits (from the spec, not measured).
3. The nonce is hidden from the DOM (measured)
Inside the page, script.getAttribute('nonce') returned "" and outerHTML showed nonce="". The real value was still on the script.nonce property. Chromium hides it so injected CSS selectors cannot read it out of the attribute. If your code copies nonces to new scripts, read .nonce, not getAttribute.
4. 'strict-dynamic': when to add it
Without 'strict-dynamic', a nonced script that loads another script (analytics tags, lazy loaders) is blocked unless that URL is also allowed. With it, trust passes to scripts a nonced script creates. Measured above: the createElement script loaded, while a plain un-nonced <script src> in the HTML was still blocked.
From the spec, not measured: when 'strict-dynamic' is present, browsers that support it ignore host lists and 'self' in script-src, so older browsers fall back to those.
5. Do not reuse the nonce
A nonce that stays the same across page loads gives no protection: an attacker loads your page once, reads the nonce from the header, and adds it to their injected <script>. Generate a new one per response. This also means a page with a nonce cannot be served as a single cached copy to everyone (a CDN cache would serve one nonce to every visitor). For fully static pages, hashes ('sha256-...') are the usual choice (from the docs, not measured).
6. Roll it out safely
- Ship the policy first as Content-Security-Policy-Report-Only and read the reports.
- Move
onclick=and other inline handlers intoaddEventListenerin a nonced script. A nonce does not cover them. - Remove
'unsafe-inline'once nonces work, or keep it only as a fallback for very old browsers (newer ones ignore it, as measured). - A strict CSP replaces the old X-XSS-Protection header, which modern browsers ignore.
Frequently asked questions
What is a CSP nonce?
A random, single-use value sent in the Content-Security-Policy header as 'nonce-VALUE' and on each trusted script tag as nonce="VALUE". In Chromium 153 only scripts with the matching nonce ran; wrong or missing nonces were blocked.
Does 'unsafe-inline' still work with a nonce?
No. With script-src 'nonce-abc' 'unsafe-inline', Chromium 153 blocked an inline script with no nonce and logged that 'unsafe-inline' is ignored if a hash or nonce is present.
Does a nonce allow onclick handlers?
No. Chromium 153 blocked an onclick handler under script-src 'nonce-...'. Move the handler into a nonced script with addEventListener.
Why does getAttribute('nonce') return an empty string?
Chromium hides the nonce from the attribute after load. In our test getAttribute('nonce') returned "" while script.nonce returned the real value.
How do I generate a nonce in Express?
Set res.locals.nonce = crypto.randomBytes(16).toString('base64') in a middleware, then in helmet use scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.nonce}'`]. Measured with Express 5.2.1 and helmet 8.3.0: a new nonce every request.
Can I use the same nonce on every page load?
No. A fixed nonce can be read by anyone and copied into injected script tags, so it gives no protection. Generate a new one for every response.
Written with AI assistance. Measured on 2026-10-02 with headless Chromium 153.0.8010.12 (Playwright) against Express 5.2.1 and helmet 8.3.0 on Node.js 24.21.0 (localhost:8985), headers checked with curl. Points marked as from the spec are quoted from the CSP Level 3 spec and MDN, not measured. PlainHeaders is our extension; Chromium, Node.js, Express and helmet are free.