Modern frontend tooling makes CSP both easier and more annoying.

Easier, because production bundles are usually external files with stable paths and hashes. Annoying, because dev servers inject scripts, HMR opens WebSockets, CSS often lands inline during development, and plugins sneak in behavior you did not account for.

If you run Vite, Rollup, webpack, Parcel, or esbuild-based stacks, the trick is simple: treat development CSP and production CSP as separate policies. Trying to force one policy to fit both is how teams end up shipping 'unsafe-inline' everywhere.

The short version

For modern build tools:

  • Production should usually avoid 'unsafe-inline' and 'unsafe-eval'
  • Development often needs:
    • ws: or wss: for HMR
    • looser script-src for injected client code
    • looser style-src because styles are commonly injected inline
  • Nonce-based CSP works best when your server renders HTML
  • Hash-based CSP works best for static HTML with stable inline snippets
  • If you use 'strict-dynamic', understand what it does before cargo-culting it

A solid production CSP for a Vite app

For a plain Vite SPA deployed from your own origin, this is a good starting point:

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

That works if:

  • your JavaScript is served as external files from the same origin
  • your CSS is external
  • you are not injecting inline scripts
  • you are not using third-party analytics, consent tools, or CDNs

A lot of Vite production builds fit this pretty well.

Why Vite dev breaks strict CSP

Vite development mode is not your production app. It injects a dev client, uses HMR, and often relies on inline style updates. If you try your production policy in dev, the browser console will light up.

A practical Vite dev CSP looks more like this:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'unsafe-inline' 'unsafe-eval';
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;
  font-src 'self' data:;
  connect-src 'self' ws: wss: http: https:;
  object-src 'none';
  base-uri 'self';
  frame-ancestors 'none';

Do I like 'unsafe-inline' and 'unsafe-eval' here? No. But for local development, I care more about keeping CSP visible than pretending dev can always match prod.

If you want stricter dev CSP, expect some wrestling with plugins and HMR behavior.

Split dev and prod headers cleanly

If you serve Vite behind Node or a reverse proxy, keep two policies.

Express example

import express from 'express'
import path from 'node:path'

const app = express()
const isDev = process.env.NODE_ENV !== 'production'

app.use((req, res, next) => {
  const csp = isDev
    ? [
        "default-src 'self'",
        "script-src 'self' 'unsafe-inline' 'unsafe-eval'",
        "style-src 'self' 'unsafe-inline'",
        "img-src 'self' data: https:",
        "font-src 'self' data:",
        "connect-src 'self' ws: wss: http: https:",
        "object-src 'none'",
        "base-uri 'self'",
        "frame-ancestors 'none'",
      ].join('; ')
    : [
        "default-src 'self'",
        "script-src 'self'",
        "style-src 'self'",
        "img-src 'self' data: https:",
        "font-src 'self'",
        "connect-src 'self'",
        "object-src 'none'",
        "base-uri 'self'",
        "form-action 'self'",
        "frame-ancestors 'none'",
      ].join('; ')

  res.setHeader('Content-Security-Policy', csp)
  next()
})

app.use(express.static(path.join(process.cwd(), 'dist')))
app.listen(3000)

That gets you out of the “we disabled CSP because Vite dev was noisy” trap.

Nonces with Vite server-rendered apps

If your app server renders HTML on each request, nonce-based CSP is usually the cleanest setup.

Generate a nonce per request:

import crypto from 'node:crypto'

function makeNonce() {
  return crypto.randomBytes(16).toString('base64')
}

Use it in the response header:

app.use((req, res, next) => {
  const nonce = makeNonce()
  res.locals.nonce = nonce

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

  next()
})

Then attach the nonce to inline tags in your HTML template:

<!doctype html>
<html>
  <head>
    <meta charset="UTF-8" />
    <script type="module" nonce="{{nonce}}" src="/assets/main.js"></script>
    <style nonce="{{nonce}}">
      .app-loading { opacity: 0.6; }
    </style>
  </head>
  <body>
    <div id="app"></div>
    <script nonce="{{nonce}}">
      window.__BOOTSTRAP__ = { userId: "123" }
    </script>
  </body>
</html>

For static Vite output, nonces are harder unless your edge or origin rewrites HTML dynamically.

Hashes for static inline snippets

If you have a tiny inline bootstrap script in index.html, hashes are better than giving up and adding 'unsafe-inline'.

Example policy:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'sha256-AbCdEf1234567890examplehashhere=';
  style-src 'self';
  img-src 'self' data: https:;
  object-src 'none';
  base-uri 'self';
  frame-ancestors 'none';

This is a good fit for static hosting where the HTML is built once and served as-is.

About 'strict-dynamic'

You will see policies like this in the wild. Here is the real CSP 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-Mzk3ZjNkY2EtNTk5YS00ODIyLWE3NzAtOGQzYzg4ZGY1NjE2' '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'

A few things are going on there:

  • script-src uses a nonce
  • it also uses 'strict-dynamic'
  • several third-party domains are allowed for analytics and consent tooling
  • style-src still has 'unsafe-inline', which is common when third-party tools inject styles

'strict-dynamic' tells the browser to trust scripts loaded by a trusted nonce- or hash-bearing script. This is powerful, but it changes how host allowlists are interpreted in supporting browsers. If you use it, do it on purpose.

For most Vite apps without dynamic third-party script loaders, I would keep it simpler:

script-src 'self' 'nonce-...'

Use 'strict-dynamic' when you actually need trust propagation from your bootstrapped script.

Third-party services are where CSP gets ugly

Analytics, tag managers, consent banners, A/B testing, embedded chat, and error trackers are why clean policies become long policies.

If your Vite app includes Google Tag Manager and Cookiebot, your CSP starts to look a lot more like the headertest example than the minimal one.

A realistic production policy:

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

I would not start here unless you actually use those services. Copying big vendor-heavy policies into a simple app is how dead rules accumulate.

For ready-to-use policy patterns, https://csp-examples.com can save time.

Vite config does not set CSP by itself

Vite does not own your CSP. Your server, CDN, reverse proxy, or hosting platform does.

Still, you should think about CSP while configuring build output:

import { defineConfig } from 'vite'

export default defineConfig({
  build: {
    sourcemap: false,
  },
  server: {
    port: 5173,
  },
})

The security-relevant part is not some magic Vite flag. It is whether your app emits inline code, pulls assets from CDNs, or depends on plugins that inject runtime content.

Common breakages and what directive to check

When something fails, the browser console usually tells you exactly which directive blocked it.

Use this cheat sheet:

  • HMR WebSocket fails
    Check connect-src
    Add ws: / wss: or the dev server origin

  • Inline <script> blocked
    Check script-src
    Use a nonce or hash; avoid 'unsafe-inline' in prod

  • Inline styles blocked
    Check style-src
    Use external CSS, a nonce, or accept looser dev policy

  • Dynamic import or third-party loader fails
    Check script-src
    Maybe you need specific origins or 'strict-dynamic'

  • API calls fail
    Check connect-src
    Add your API origin, analytics endpoints, and WebSocket endpoints

  • Embedded iframe fails
    Check frame-src

  • App can be embedded by other sites
    Check frame-ancestors
    Usually set it to 'none' or a strict allowlist

My default recommendation

For most teams using Vite or similar tools:

  • keep dev CSP permissive enough to preserve workflow
  • keep prod CSP strict enough to block inline execution by default
  • prefer nonces for server-rendered HTML
  • prefer hashes for static inline snippets
  • avoid adding third-party domains until the console proves you need them
  • do not leave 'unsafe-inline' in production just because dev needed it

If you want the canonical directive behavior and browser details, the official references are the MDN and W3C documentation. That is where I go when browser behavior gets weird.