Runtime

Content Security Policy in Node.js

In a Node.js app the policy is one middleware, in Express usually Helmet. It is easy to get right on the route you test and wrong on the responses you did not think of as routes.

A complete report-only config

Express 5 with Helmet 8, in report-only mode, with a fresh nonce per response. Only the application knows which scripts it rendered, so the nonce is generated here and not at a proxy in front of it.

server.mjs
import crypto from "node:crypto";
import express from "express";
import helmet from "helmet";

const ENDPOINT = "https://your-ingest-code.ingest.us.headerhawk.com/r";

const app = express();

// A fresh nonce per response, created before anything that can answer.
app.use((req, res, next) => {
  res.locals.cspNonce = crypto.randomBytes(16).toString("base64");
  next();
});

// Registered before express.static, or static files are sent without it.
app.use(
  helmet({
    contentSecurityPolicy: {
      // Sends Content-Security-Policy-Report-Only rather than the enforcing header.
      reportOnly: true,
      // Helmet's defaults include upgrade-insecure-requests, which a
      // report-only policy cannot apply. Spell the policy out instead.
      useDefaults: false,
      directives: {
        defaultSrc: ["'self'"],
        scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
        styleSrc: ["'self'"],
        imgSrc: ["'self'", "data:"],
        fontSrc: ["'self'"],
        connectSrc: ["'self'"],
        objectSrc: ["'none'"],
        baseUri: ["'self'"],
        formAction: ["'self'"],
        frameAncestors: ["'self'"],
        reportUri: [ENDPOINT],
        reportTo: ["csp-endpoint"],
      },
    },
  }),
);

app.use((req, res, next) => {
  res.setHeader("Reporting-Endpoints", `csp-endpoint="${ENDPOINT}"`);
  next();
});

app.use(express.static("public"));

app.get("/", (req, res) => {
  res.send(`<!doctype html>
<title>Example</title>
<script nonce="${res.locals.cspNonce}">console.log("runs: it carries the nonce");</script>
<script>console.log("reported: it does not");</script>`);
});

app.listen(process.env.PORT ?? 3000);

How this was checked: run on Node.js 24 with Express 5.2.1 and Helmet 8.3.0. Both headers arrive on the rendered page, on a file served by express.static and on a 404; in the browser the nonced script runs and the other one is reported.

Gotcha

helmet() enforces a policy you never wrote

Helmet is usually installed for its other headers, with a bare app.use(helmet()). That line also sends Helmet's default enforcing Content-Security-Policy, including script-src 'self', script-src-attr 'none' and upgrade-insecure-requests.

Nothing in that line says CSP, so when an inline script, a tag manager or an onclick attribute stops working, the policy is not the first suspect, and nothing reports it where you would look. To roll out a policy of your own, configure contentSecurityPolicy explicitly.

// Sends an ENFORCING Content-Security-Policy, as well as the other headers:
app.use(helmet());

// Sends the policy you wrote, in report-only mode:
app.use(
  helmet({
    contentSecurityPolicy: { reportOnly: true, useDefaults: false, directives: { /* ... */ } },
  }),
);

reportOnly: true on its own keeps Helmet's defaults, including upgrade-insecure-requests, and the browser says so on every page load:

Browser console
The Content Security Policy directive 'upgrade-insecure-requests' is ignored
when delivered in a report-only policy.

The config sets useDefaults: false, so the policy that ships is the one written in the file.

Gotcha

Middleware order decides which responses carry the header

Express runs middleware in the order it was registered, and express.static ends the request as soon as it finds a file. A header set by anything registered after it never reaches those files.

// public/index.html is sent before the policy is ever set:
app.use(express.static("public"));
app.use(helmet({ contentSecurityPolicy: { reportOnly: true /* ... */ } }));

// Every response carries it:
app.use(helmet({ contentSecurityPolicy: { reportOnly: true /* ... */ } }));
app.use(express.static("public"));

In a test app with express.static first, the rendered route had the policy and the index.html beside it had none. In a single-page app that static index.html is the page the policy is for, and a check against an API route looks fine.

Register the nonce and Helmet before static files, routers and anything else that can send a response, as the config does.

Now see what it's blocking

A report-only policy blocks nothing. When a browser loads a page and finds something the policy would have refused, it posts a report to the endpoint in report-uri and report-to. Until something listens there, those reports go nowhere.

HeaderHawk is one place to send them. It accepts both report formats on the same URL, groups them by directive and blocked host so a thousand reports about one script read as one issue, and keeps extension and bot reports visible but out of alerts. Replace your-ingest-code in the config above with the code from your site's setup panel, or paste the URL the panel shows.

The Node.js integration guide covers that step: where the endpoint goes on Node.js, and how to confirm the headers are being served.

A policy is the first of several controls browsers enforce on the code your pages load, including Integrity-Policy, Permissions-Policy, COOP and COEP, and they all report to the same endpoint. What client-side security is covers the rest of that layer.

Start reading the reports

Free for 1 site. Leave the policy in report-only until the reports go quiet, then enforce it.