Skip to content
script-src.com
script-src.com
EN / FR
← All guides

Why your dev server breaks under script-src

By Thomas Poyeton

script-src unsafe-eval vite webpack dev-server

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.