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

# 3. Stamp the same value onto the inline 

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:

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.

Per-request nonce flow through Express middleware and Helmet An Express request passes through nonce middleware, then Helmet builds the CSP using that nonce, and the response plus template both carry the same value. Request Express nonce middleware res.locals helmet builds CSP with nonce response + template same nonce order matters: nonce must exist before helmet runs
The nonce middleware sets res.locals.nonce first; Helmet then reads it to build the CSP header, and the template stamps the identical value onto inline scripts.

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.

Edge Cases, Security Implications & Safe Rollback

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:

  1. Switch to monitoring without enforcement: add reportOnly: true to the contentSecurityPolicy options so violations are reported but nothing is blocked.
  2. Or remove the contentSecurityPolicy block from the helmet(...) call and redeploy; Helmet’s other headers remain.
  3. Verify with the cache-aware curl above 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.