Configuring CSP with Helmet and Per-Request Nonces
This guide is part of the Node and Express Helmet configuration reference and shows the exact way to build a nonce-based Content-Security-Policy with Helmet. The core constraint: the nonce must be generated before helmet runs so the same value can be referenced in the policy and injected into the rendered template. For the cryptographic detail of minting the value itself, see generating CSP nonces per request.
Configuration Syntax & Exact Values
Three pieces have to agree on one value per request:
<!-- 1. Generate the nonce (middleware, before helmet) -->
res.locals.nonce = crypto.randomBytes(16).toString('base64')
<!-- 2. Reference it in the CSP scriptSrc directive -->
script-src 'self' 'nonce-<value>'
<!-- 3. Stamp the same value onto the inline <script> tag -->
<script nonce="<value>"> ... </script>
The Helmet directive that produces the header is helmet.contentSecurityPolicy with a directives object. Because Helmet builds the header value once per response, scriptSrc must reference the nonce through a function that reads res.locals at request time, not a static string:
scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.nonce}'`]
Annotated breakdown of the directive set:
defaultSrc ["'self'"]— fallback for any fetch directive not listed; restricts to same origin.scriptSrc ["'self'", (req, res) => ...]— the nonce function is evaluated per response, so each page gets its current nonce. A literal'nonce-...'string would freeze one value across all requests and break.styleSrc ["'self'"]— extend with a nonce too if you have inline<style>.objectSrc ["'none'"]— blocks<object>/<embed>plugin vectors.baseUri ["'none'"]— stops<base>tag injection from rewriting relative URLs.frameAncestors ["'none'"]— the CSP equivalent ofX-Frame-Options: DENY.
Server-Side Configuration
Nonce middleware + Helmet config order
The nonce generator must be registered before helmet, because res.locals.nonce has to exist at the moment Helmet evaluates the scriptSrc function. Reversing the order yields 'nonce-undefined', which silently disables inline scripts.
const crypto = require("crypto");
const express = require("express");
const helmet = require("helmet");
const app = express();
// 1. Mint a fresh nonce per request — MUST run before helmet.
app.use((req, res, next) => {
res.locals.nonce = crypto.randomBytes(16).toString("base64");
next();
});
// 2. Build the CSP, reading the nonce at response time.
app.use(
helmet({
contentSecurityPolicy: {
useDefaults: false,
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.nonce}'`],
styleSrc: ["'self'"],
objectSrc: ["'none'"],
baseUri: ["'none'"],
frameAncestors: ["'none'"],
},
},
})
);
useDefaults: false means the directive set above is the entire policy — nothing is silently merged in. With useDefaults: true (the default), Helmet adds its baseline directives and merges yours on top, which is convenient but can mask a missing directive. State the policy explicitly for a security reference.
Template usage (EJS / Pug)
The view must stamp res.locals.nonce onto every inline <script>. Express exposes res.locals directly to the template, so nonce is in scope.
<!-- EJS -->
<script nonce="<%= nonce %>">
window.__INIT__ = { ready: true };
</script>
//- Pug
script(nonce=nonce)
| window.__INIT__ = { ready: true };
External scripts loaded from 'self' do not need the attribute; only inline scripts require the nonce to execute under the policy.
Adding ‘strict-dynamic’
'strict-dynamic' lets a nonced script load further scripts it trusts (bundlers, dynamic imports) without each new script needing its own nonce. Modern browsers then ignore 'self' and host-allowlists for scripts, trusting only the nonce chain — a stronger posture.
scriptSrc: [
"'strict-dynamic'",
(req, res) => `'nonce-${res.locals.nonce}'`,
],
With 'strict-dynamic' present, older browsers that do not understand it fall back to the 'self' / host sources, so keep 'self' listed only if you must support them; otherwise the nonce alone is the gate.
The mechanism is trust propagation: a script that executed because its nonce matched is allowed to insert further scripts via document.createElement('script') or import(), and those descendants execute without carrying a nonce of their own. This is what lets a webpack or Vite runtime load its chunk files: you nonce only the single bootstrap tag the bundler emits, and every chunk it pulls in inherits trust. The trade-off is that under 'strict-dynamic' the browser deliberately ignores 'self' and every host allowlist entry for script-src — a <script src="https://cdn.example/a.js"> written directly in your HTML will be refused unless it also carries the nonce, because only script-inserted descendants inherit trust, not statically authored tags. Audit your templates for hardcoded external <script src> tags before switching this on.
For maximum compatibility across a mixed browser fleet, the recommended scriptSrc is ["'strict-dynamic'", nonceFn, "'unsafe-inline'", "https:"]: modern browsers honour 'strict-dynamic' and the nonce and ignore the rest, while pre-CSP3 browsers ignore 'strict-dynamic', reject the nonce they cannot parse, and fall back to 'unsafe-inline' + https:. The two rulesets never both apply, so this is safe rather than contradictory. If you have dropped support for CSP2-only browsers, delete the fallback entries and let the nonce chain stand alone.
Diagnostic & Verification Steps
# The CSP nonce must change between two requests
for i in 1 2; do
curl -sI http://localhost:3000/ | grep -i 'content-security-policy'
done
Expected output (two different nonce values):
content-security-policy: default-src 'self'; script-src 'self' 'nonce-Yk3pQ2vF8sLm1aWtZ0bQdg=='; style-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'
content-security-policy: default-src 'self'; script-src 'self' 'nonce-Rt9xN4cJ7uPe2bXsY1aKfw=='; style-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'
# Confirm the header nonce matches the inline <script nonce> in the same response
curl -s http://localhost:3000/ -D - -o body.html | grep -i 'content-security-policy'
grep -o 'nonce="[^"]*"' body.html
The nonce- value in the header and the nonce="..." attribute in the body must be identical. If they differ, the middleware ran after Helmet, or the template read a different variable. In browser DevTools, the Console reports a CSP violation like Refused to execute inline script because it violates ... nonce-... whenever the values fail to match.
# Confirm exactly one CSP header — a proxy duplicate returns 2
curl -sI http://localhost:3000/ | grep -ci '^content-security-policy:'
Expected output: 1.
When 'strict-dynamic' is enabled, add one more check: statically authored external tags must now be script-inserted or nonced. Load the page with DevTools open and confirm no Refused to load the script 'https://...' because it violates ... strict-dynamic appears. A violation here means a <script src> is hardcoded in a template and no longer inherits trust — either move it behind the bundler runtime or stamp the nonce onto it.
The three failures a nonced policy produces in practice all surface as the same browser symptom — an inline script that will not run — but each has a distinct root cause. Work the checks above in order; the decision tree below maps each failing check to the fix.
Edge Cases, Security Implications & Safe Rollback
- Middleware order.
res.locals.noncemust be set beforehelmetis registered. If Helmet runs first, thescriptSrcfunction readsundefinedand emits'nonce-undefined', blocking every inline script. Register the nonce middleware as the firstapp.use. - Caching breaks nonces. A nonce is valid for exactly one response. If a page with a nonced CSP is cached — by a CDN, reverse proxy, or
Cache-Control— later visitors get a stale nonce that no longer matches the served HTML, and all inline scripts are refused. SendCache-Control: no-storeon nonced HTML routes, or render them dynamically and never cache. - Reverse proxy duplicate CSP. If Nginx or a CDN in front of Express also injects
Content-Security-Policy, the client receives two headers and enforces their intersection, often silently breaking the nonced policy. Set the header in exactly one layer; verify with thegrep -cicheck returning1. See the Nginx headers reference for stripping a duplicate at the proxy. useDefaults. WithuseDefaults: true, Helmet merges its baseline directives under yours, which can silently re-add a directive you meant to drop. For an auditable policy useuseDefaults: falseand list every directive explicitly, as above.- Nonce entropy and encoding. The CSP specification requires at least 128 bits of entropy per nonce;
crypto.randomBytes(16)yields exactly 128 bits, so do not shrink it. Usecrypto.randomBytes, neverMath.random()— the latter is not cryptographically secure and its output is predictable, which lets an attacker guess the nonce and bypass the policy. Base64 output is fine; the CSP grammar accepts standard base64 (+,/,=) inside'nonce-...', so no base64url conversion is needed. Generate one value per request and never reuse it across responses. - Nonces on inline styles.
scriptSrcandstyleSrcare independent. If you also emit inline<style>blocks and want to drop'unsafe-inline'from styles, add a second per-response function tostyleSrc:styleSrc: ["'self'", (req, res) =>‘nonce-${res.locals.nonce}’], and stamp the samenonceattribute onto each<style>tag. Note that'strict-dynamic'has no meaning for styles — it applies only toscript-src. - WebSocket, worker, and connect sources. A nonce governs script execution, not network destinations. If nonced code opens a WebSocket or calls
fetch, those endpoints are still gated byconnect-src, and workers byworker-src; add them explicitly or the requests are blocked independently of the nonce being valid.
Safe rollback. Helmet changes are code-level and non-destructive. To revert, either remove the nonce function from scriptSrc and the nonce middleware, or move to a report-only policy while you debug:
- Switch to monitoring without enforcement: add
reportOnly: trueto thecontentSecurityPolicyoptions so violations are reported but nothing is blocked. - Or remove the
contentSecurityPolicyblock from thehelmet(...)call and redeploy; Helmet’s other headers remain. - Verify with the cache-aware
curlabove that the header reverts and inline scripts execute.
Frequently Asked Questions
Why must the nonce middleware run before Helmet?
Helmet evaluates the scriptSrc function while building the response header, and that function reads res.locals.nonce. If the value has not been set yet, it resolves to undefined and the header becomes 'nonce-undefined', which matches no script and blocks all inline JavaScript. Register the nonce middleware as the first app.use.
Why is my nonce a function and not a string in scriptSrc?
A static string is computed once and frozen across every request, so all visitors share one nonce — defeating the point. The (req, res) => ‘nonce-${res.locals.nonce}’`` form is re-evaluated for each response, so each page carries its own fresh nonce that matches its rendered HTML.
Can I cache a page that uses a CSP nonce?
No. A nonce is single-use per request. A cached page serves a stale nonce that will not match the policy on later requests, refusing every inline script. Send Cache-Control: no-store on nonced routes, or render them dynamically without caching.
Should I set useDefaults to true or false?
Use false for a security reference so the directive set you write is the complete policy. true merges Helmet’s defaults underneath yours, which is convenient but can silently restore a directive you intended to remove, masking gaps during an audit.
Conclusion
Roll out the nonce-based policy in reportOnly: true mode first on staging, confirm the header and template nonces match and no legitimate inline script is reported, then flip to enforcing in production. Keep the nonce middleware first, mark nonced routes no-store, and ensure no proxy adds a second CSP header.