Why your dev server breaks under script-src
By Thomas Poyeton
Your policy is fine. Development servers depend on exactly the things a strict
script-src is built to stop: executing strings as code and injecting inline
scripts. Webpack runs rebuilt modules through eval. Vite avoids that but
injects an inline preamble script for React Fast Refresh, and both push updates
over a websocket. Production needs none of this, so the answer is a short list
of dev-only allowances, not a weaker production policy.
The eval error comes from webpack's devtool
The first thing most people see after adding script-src to a webpack project
is a blank page and this:
Refused to evaluate a string as JavaScript because 'unsafe-eval' is not an
allowed source of script in the following Content Security Policy directive:
"script-src 'self'".
No code in the project calls eval. Webpack does. In mode: 'development',
the devtool option defaults to eval: every module is compiled into a string
and executed with eval(), with a //# sourceURL comment appended so stack
traces still point at the original file. Rebuilds are fast because only the
changed module's string gets re-evaluated. Next.js and Vue CLI hit the same
wall in development because their dev builds run through the same eval-based
tooling.
You have two ways out. The first is to keep webpack's default and add
'unsafe-eval' to the policy your dev server sends, and only that policy. The
second is to pick a devtool that does not use eval:
// webpack.config.js
module.exports = {
devtool: 'cheap-module-source-map',
};
Any value starting with eval- reintroduces the requirement, and the non-eval
variants rebuild more slowly on large projects. That slowdown is why the
dev-only 'unsafe-eval' allowance is usually the better trade.
Vite fails on an inline script instead
Vite serves your source files as native ES modules, so application code runs
without eval. What breaks is smaller and harder to diagnose. With
@vitejs/plugin-react, the dev server injects a short inline module script
into the page to install the React Fast Refresh runtime. A script-src
without 'unsafe-inline' blocks that script, and the symptom you get is not a
CSP error but this:
@vitejs/plugin-react can't detect preamble. Something is wrong.
The actual cause, a "Refused to execute inline script" message, sits a few
lines earlier in the console. In development the practical fix is
'unsafe-inline' in the dev policy. Vite 5.3 added an html.cspNonce option
that stamps a nonce attribute on every tag Vite injects, but a nonce only
helps when something serves the HTML and swaps in a fresh value per response,
which a plain vite dev setup does not do. Vite also inserts styles as inline
<style> tags during development, so style-src needs the same dev allowance.
If hot updates stop applying and the console shows a refused connection to
ws://localhost:5173/, that is not script-src at all. The HMR channel is a
websocket, governed by connect-src.
Two servers means two origins
The trickier setup is a backend (Rails, Django, Express) that renders the HTML
and sends the CSP header, while Vite or webpack-dev-server serves the
JavaScript from another port. 'self' no longer matches your own scripts,
because a different port is a different origin. The dev policy has to name the
dev server explicitly:
Content-Security-Policy: script-src 'self' 'unsafe-eval' http://localhost:5173; connect-src 'self' ws://localhost:5173
Swap the port for whatever your dev server uses (webpack-dev-server defaults to 8080). None of these entries belong in the production header, which leads to the last point.
Branch the policy on environment, then check what ships
Keep one strict production policy and one relaxed development policy, selected where the header is set:
const dev = process.env.NODE_ENV !== 'production';
const csp = dev
? "script-src 'self' 'unsafe-eval' http://localhost:5173; connect-src 'self' ws://localhost:5173"
: `script-src 'nonce-${nonce}' 'strict-dynamic'`;
res.setHeader('Content-Security-Policy', csp);
The failure mode to guard against is shipping the dev branch of that
conditional. 'unsafe-eval' in production re-opens string-to-code execution
for every script on the page, and nothing in a production bundle needs it:
webpack and Vite both emit eval-free code at build time. Before a release,
paste the production header into
CentralCSP's evaluator. It flags
'unsafe-eval' and 'unsafe-inline' the moment they leak out of development.