How to Handle CSP with Webpack 5

Table of Contents

Content Security Policy and Webpack 5 have a slightly annoying relationship.

Webpack wants to inject runtime code, lazy-load chunks, and smooth over browser differences. CSP wants you to be explicit about every script, style, and connection. If you just turn on a strict policy after shipping a typical Webpack app, something usually breaks.

The good news: Webpack 5 works fine with CSP if you wire it up deliberately.

This guide is the version I wish I had the first time I locked down a production bundle.

The short version

If you’re using Webpack 5 and want a solid CSP setup:

  • avoid inline scripts unless they have a nonce or hash
  • avoid unsafe-inline for scripts
  • use nonces for server-rendered script tags
  • set __webpack_nonce__ so Webpack-added <script> tags inherit the nonce
  • check dynamic imports, styles, dev tooling, and third-party scripts
  • use report-only first before enforcing

If you want ready-made policy examples for different setups, csp-examples.com is handy.

What usually breaks with Webpack 5

Webpack itself isn’t the problem. The trouble usually comes from these patterns:

  1. Inline runtime bootstrap code
  2. Dynamically injected chunk scripts from import()
  3. Style injection from style-loader
  4. Webpack dev server using eval in development
  5. Third-party analytics or consent tools
  6. WebSocket connections during development

That means your CSP has to account for both your app code and Webpack’s runtime behavior.

Start with a sane production CSP

Here’s a practical baseline for a self-hosted Webpack app:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic';
  style-src 'self';
  img-src 'self' data: https:;
  font-src 'self';
  connect-src 'self';
  base-uri 'self';
  form-action 'self';
  frame-ancestors 'none';
  object-src 'none';

A few opinions here:

  • object-src 'none' should be standard.
  • base-uri 'self' is cheap hardening.
  • frame-ancestors 'none' is great unless you intentionally allow embedding.
  • strict-dynamic is worth using if you’re already using nonces.

Real-world example

A production CSP usually grows once analytics, consent, and APIs get involved. Here’s a real header from headertest.com:

content-security-policy: default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com; script-src 'self' 'nonce-ZTZlNGMyNmUtNmM5NC00MWQ2LWE4MzUtMTQzNjZmMzg4ZWYw' 'strict-dynamic' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com; style-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://*.cookiebot.com https://consent.cookiebot.com; img-src 'self' data: https:; font-src 'self'; connect-src 'self' https://api.headertest.com https://tallycdn.com https://or.headertest.com wss://or.headertest.com https://*.google-analytics.com https://*.googletagmanager.com https://*.cookiebot.com; frame-src 'self' https://consentcdn.cookiebot.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'

That’s a good reminder that real CSPs are rarely tiny. They reflect the actual stuff your frontend loads.

Use a nonce for your entry script

If your HTML is server-rendered, generate a nonce per request and apply it to your script tag.

Express example

import crypto from "node:crypto";
import express from "express";

const app = express();

app.use((req, res, next) => {
  res.locals.nonce = crypto.randomBytes(16).toString("base64");

  res.setHeader(
    "Content-Security-Policy",
    [
      "default-src 'self'",
      `script-src 'self' 'nonce-${res.locals.nonce}' 'strict-dynamic'`,
      "style-src 'self'",
      "img-src 'self' data: https:",
      "font-src 'self'",
      "connect-src 'self'",
      "base-uri 'self'",
      "form-action 'self'",
      "frame-ancestors 'none'",
      "object-src 'none'",
    ].join("; ")
  );

  next();
});

app.get("/", (req, res) => {
  res.send(`
    <!doctype html>
    <html>
      <head>
        <meta charset="utf-8" />
        <title>Webpack CSP</title>
      </head>
      <body>
        <div id="app"></div>
        <script nonce="${res.locals.nonce}" src="/assets/main.js"></script>
      </body>
    </html>
  `);
});

app.listen(3000);

That covers your initial bundle. Now you need to handle scripts Webpack injects later.

Set __webpack_nonce__ for lazy-loaded chunks

This is the piece people miss.

When Webpack loads async chunks from import(), it creates <script> tags at runtime. Those tags need the same nonce.

Set __webpack_nonce__ before any dynamic import runs:

declare let __webpack_nonce__: string;

__webpack_nonce__ = window.__CSP_NONCE__;

import("./bootstrap");

If you’re not using TypeScript:

__webpack_nonce__ = window.__CSP_NONCE__;
import("./bootstrap");

Then expose the nonce in your HTML:

<script nonce="{{nonce}}">
  window.__CSP_NONCE__ = "{{nonce}}";
</script>
<script nonce="{{nonce}}" src="/assets/main.js"></script>

Yes, that tiny inline script also needs the nonce.

If you skip this, your initial script may run fine, but lazy-loaded routes or components will fail under CSP.

Webpack 5 config that behaves better with CSP

Here’s a practical production config:

const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");

module.exports = {
  mode: "production",
  entry: "./src/index.js",
  output: {
    path: path.resolve(__dirname, "dist"),
    filename: "assets/[name].[contenthash].js",
    chunkFilename: "assets/[name].[contenthash].js",
    clean: true,
    publicPath: "/",
  },
  module: {
    rules: [
      {
        test: /\.css$/i,
        use: [MiniCssExtractPlugin.loader, "css-loader"],
      },
    ],
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: "./src/index.html",
    }),
    new MiniCssExtractPlugin({
      filename: "assets/[name].[contenthash].css",
    }),
  ],
  devtool: "source-map",
};

Two choices matter here:

  • MiniCssExtractPlugin avoids runtime style injection from style-loader
  • devtool: "source-map" avoids eval-based source maps in production

Avoid style-loader in production

style-loader injects CSS via <style> tags at runtime. That often pushes people into style-src 'unsafe-inline'.

Sometimes that’s unavoidable, but if you want a tighter CSP, extract CSS into real files instead.

Bad for strict CSP

{
  test: /\.css$/i,
  use: ["style-loader", "css-loader"],
}

Better for production

const MiniCssExtractPlugin = require("mini-css-extract-plugin");

{
  test: /\.css$/i,
  use: [MiniCssExtractPlugin.loader, "css-loader"],
}

If you’re stuck with runtime-injected styles from a library, you may need:

style-src 'self' 'unsafe-inline';

I treat that as a compromise, not a default.

Webpack Dev Server is a different CSP problem

Development builds often need a looser policy.

Why? Because Webpack dev tooling may use:

  • eval for source maps
  • WebSockets for HMR
  • inline styles or scripts in some setups

A typical dev CSP might look like this:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'unsafe-eval' 'nonce-{RANDOM_NONCE}';
  style-src 'self' 'unsafe-inline';
  connect-src 'self' ws://localhost:8080 http://localhost:8080;
  img-src 'self' data: https:;
  font-src 'self';

And in your dev Webpack config, prefer avoiding eval if possible:

module.exports = {
  mode: "development",
  devtool: "cheap-module-source-map",
  devServer: {
    port: 8080,
    hot: true,
    historyApiFallback: true,
  },
};

If you use an eval-* devtool, you’ll need unsafe-eval. I avoid that unless I really need the faster rebuild behavior.

HtmlWebpackPlugin and nonces

If HtmlWebpackPlugin generates your HTML, you still need a server-side nonce because CSP nonces must be unique per request.

That means a static build alone can’t safely hardcode a nonce into the generated HTML.

The usual pattern is:

  1. build assets with Webpack
  2. render HTML from your app server
  3. inject the current request nonce into the script tags

If you’re fully static, hashes are often easier than nonces.

Hashes vs nonces with Webpack

For static inline snippets, hashes work well:

script-src 'self' 'sha256-AbCdEf123...';

But for Webpack apps with dynamic imports, I strongly prefer nonces plus strict-dynamic.

Why:

  • hashes are annoying when inline code changes
  • nonces fit request-based rendering better
  • strict-dynamic helps trusted nonce-bearing scripts load child scripts

Third-party scripts and CSP drift

Webpack apps rarely stay “self only” for long. Then someone adds GTM, analytics, Hotjar, a chat widget, and three A/B testing tools.

Every one of those changes your CSP.

Be explicit. Keep directives narrow. Don’t dump everything into default-src.

For example:

script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic' https://www.googletagmanager.com;
connect-src 'self' https://www.google-analytics.com https://region1.google-analytics.com;
img-src 'self' data: https:;
frame-src 'self';

That’s much better than spraying third-party origins into every directive.

Debug with Report-Only first

Before enforcing, ship this:

Content-Security-Policy-Report-Only:
  default-src 'self';
  script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic';
  style-src 'self';
  img-src 'self' data: https:;
  connect-src 'self';
  object-src 'none';
  base-uri 'self';
  form-action 'self';
  frame-ancestors 'none';

You can also add reporting:

Report-To: {"group":"csp-endpoint","max_age":10886400,"endpoints":[{"url":"https://example.com/csp-report"}]}
Content-Security-Policy-Report-Only: default-src 'self'; report-to csp-endpoint;

Roll out in report-only mode, fix violations, then enforce.

Copy-paste starter setups

Strict-ish production Webpack 5 app

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic';
  style-src 'self';
  img-src 'self' data: https:;
  font-src 'self';
  connect-src 'self';
  frame-ancestors 'none';
  base-uri 'self';
  form-action 'self';
  object-src 'none';

Production app with extracted CSS and analytics

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic' https://www.googletagmanager.com;
  style-src 'self';
  img-src 'self' data: https:;
  font-src 'self';
  connect-src 'self' https://www.google-analytics.com https://region1.google-analytics.com;
  frame-src 'self';
  frame-ancestors 'none';
  base-uri 'self';
  form-action 'self';
  object-src 'none';

Dev policy for webpack-dev-server

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{RANDOM_NONCE}' 'unsafe-eval';
  style-src 'self' 'unsafe-inline';
  connect-src 'self' ws://localhost:8080 http://localhost:8080;
  img-src 'self' data: https:;
  font-src 'self';

My default advice

If I’m setting up CSP on a Webpack 5 app today, I do this:

  • extract CSS, don’t inject it in production
  • use a per-request nonce
  • set __webpack_nonce__ immediately in the entry
  • use strict-dynamic
  • keep dev and prod CSP separate
  • deploy report-only first
  • audit third-party scripts aggressively

That gets you a CSP that actually works with modern bundling instead of one that looks strict on paper and falls apart the moment a lazy chunk loads.