React Content Security Policy (CSP) with a Nonce

How to add a CSP nonce to a React + Webpack app served by Node or Nginx

Velu S Gautam2 min readLearnings

Why use a nonce?

A Content Security Policy can block inline <script> and <style> tags outright, a strong defense against injected malicious code. The common workaround, the unsafe-inline directive, defeats that protection by letting any inline script run, whether you wrote it or an attacker did.

A nonce is the safer alternative. It's a random, single-use token that the server issues with every response and attaches both to the CSP header and to each legitimate inline script or style tag. The rule is simple:

A nonce must be regenerated on every request, and it must be unguessable.

When a tag's nonce attribute matches the one in the response's Content-Security-Policy header, the browser knows that inline content was deliberately placed there by the server — not injected by a third party, so it's safe to run.

For more background, see the Content Security Policy guide on web.dev, particularly the "If you absolutely must use it" section, which shows the canonical nonce pattern.

How this fits a React + Webpack build

Webpack doesn't know the nonce at build time, it can't, since a fresh one has to be generated for every incoming request. So the build only inserts a placeholder, and the server swaps that placeholder for a real nonce as each request is handled.

1. Inject a placeholder at build time

A small custom Webpack plugin, NonceInjector, walks the HTML tags that html-webpack-plugin generates and adds a nonce="_NONCE_" attribute to each <script> and <style> tag:

HTML
<script
  defer="defer"
  src="/static/js/main.0405c3f1.js"
  nonce="_NONCE_"
></script>
<link href="/static/css/main.f855e6bc.css" rel="stylesheet" nonce="_NONCE_" />
Nonce Injector Webpack Plugin
JAVASCRIPT
const HtmlWebpackPlugin = require("html-webpack-plugin");
 
class NonceInjector {
  constructor(NONCE_PLACEHOLDER) {
    this.NONCE_PLACEHOLDER = NONCE_PLACEHOLDER;
  }
  apply(compiler) {
    compiler.hooks.thisCompilation.tap("NonceInjector", (compilation) => {
      HtmlWebpackPlugin.getHooks(compilation).afterTemplateExecution.tapAsync(
        "NonceInjector",
        (compilation, callback) => {
          const { headTags } = compilation;
          headTags.forEach((tag) => {
            tag.attributes.nonce = this.NONCE_PLACEHOLDER;
          });
          callback(null, compilation);
        }
      );
    });
  }
}
 
module.exports = NonceInjector;

2. Replace the placeholder at request time

This is where the server comes in: every incoming request has to generate a fresh random nonce, then substitute it for _NONCE_ in both the HTML it serves and the Content-Security-Policy header it returns.

The flow looks like this:

Server (Node or Nginx)BrowserServer (Node or Nginx)Browseralt[Nonce matches][Nonce missing ormismatched]GET request for pageGenerate a random, unique nonceReplace _NONCE_ in HTML with the nonceSet header: Content-Security-Policy: script-src 'nonce-<value>'HTML + CSP header (same nonce in both)Compare each tag's nonce to the CSP header's nonceExecute the inline script/styleBlock execution

To confirm the nonce actually changes on every request, you need a running server-and-client setup like the sample projects below use Docker for this. There are two implementations here: one with Node.js & one with Nginx.

Node.js (Express)

Express.js serves the built React app and replaces the nonce placeholder with a freshly generated nonce on every request.

GitHub: Express.js server sample project

Node uses the Buffer and Crypto modules to generate the random nonce, see server.js.

Nginx

Nginx serves the built React app and replaces the nonce placeholder with a freshly generated nonce on every request.

GitHub: Nginx server sample project

Nginx uses njs scripting to generate the random nonce string. See the Nginx config file for how it's wired in.

Verifying it works

Check the CSP header. Inspect any request and confirm the Content-Security-Policy header is present in the response, with a nonce value. Every script and style tag in that response should carry the same nonce.

Check the tags themselves. Inspect the DOM and confirm the nonce attribute is present on the script and style tags, matching the header.

Wrap-up

If you already have a React + Webpack app served by Node or Nginx, you can drop in the NonceInjector plugin above along with one of these two server setups to add CSP nonce support.


Cheers ✌️

Comments