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-inlinefor 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:
- Inline runtime bootstrap code
- Dynamically injected chunk scripts from
import() - Style injection from
style-loader - Webpack dev server using
evalin development - Third-party analytics or consent tools
- 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-dynamicis 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:
MiniCssExtractPluginavoids runtime style injection fromstyle-loaderdevtool: "source-map"avoidseval-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:
evalfor 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:
- build assets with Webpack
- render HTML from your app server
- 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-dynamichelps 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.