Skip to content

Instantly share code, notes, and snippets.

@rbonestell
Last active August 27, 2025 02:17
Show Gist options
  • Select an option

  • Save rbonestell/4fcba81d05413f4e27bab8ebf787624c to your computer and use it in GitHub Desktop.

Select an option

Save rbonestell/4fcba81d05413f4e27bab8ebf787624c to your computer and use it in GitHub Desktop.

Secure Your Vite SPA with a Strict Content Security Policy

If you run a modern Vite SPA without a strict Content Security Policy (CSP), you’re one XSS away from a full account takeover.
This guide walks you through hardening your Vite + React + TypeScript SPA with a strong CSP using Subresource Integrity (SRI) and per-request nonces, while avoiding common pitfalls that silently weaken security.

Why a Strong CSP Matters

A Content Security Policy is a powerful defense-in-depth control that mitigates XSS, data exfiltration, and injection attacks by explicitly allowing only trusted sources for scripts, styles, images, fonts, and network calls.

With the right CSP, you can:

  • Block unexpected script execution by removing unsafe-inline and unsafe-eval.
  • Restrict network calls via connect-src to reduce exfiltration risk.
  • Prevent clickjacking with frame-ancestors.
  • Control navigation with base-uri.
  • Get visibility into violations via report-to or report-uri.

SRI and Nonces — Better Together

  • Subresource Integrity (SRI):
    Adds an integrity attribute to <script> and <link> tags with a cryptographic hash of the file contents.
    Browsers verify the file matches the hash before executing/applying it, blocking tampering in transit or at rest.

  • Nonces:
    A unique, per-response random value in a nonce attribute on <script> and <style> tags.
    The CSP header lists this allowed nonce (script-src 'nonce-abc123'). Only tags with this nonce can execute inline code.

  • Why use both?

    • SRI = Integrity (protects external files from modification).
    • Nonce = Authorization (controls which inline code can run).
    • Together, they close the gap between static and dynamic resource execution.

Step 1: Implement SRI Automatically

Use vite-plugin-sri-gen to attach SRI hashes to emitted <script> and <link> tags at build time.

// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react-swc';
import sri from 'vite-plugin-sri-gen';

export default defineConfig({
  plugins: [react(), sri()],
});

Step 2: Avoid Inline Scripts & Styles

A strict CSP without unsafe-inline blocks:

  • <script> tags without a nonce or hash.
  • <style> tags without a nonce or hash.
  • Inline style attributes (e.g., style="color:red") unless style-src-attr is allowed.

Best practices:

  • Keep index.html free of inline scripts.
    Use html.cspNonce so any unavoidable inline helpers get a nonce.
  • Disable asset inlining:
    build: { assetsInlineLimit: 0 }
    This avoids data: URLs in scripts/styles, simplifying CSP.
  • Avoid dangerouslySetInnerHTML in React; if unavoidable, sanitize with DOMPurify.
  • Watch for libraries injecting <style> tags — ensure they get the current nonce.
  • Note: Inline style="" attributes cannot be nonced; enabling them requires style-src-attr (not recommended).

Step 3: Generate and Inject Nonces at the Web Server

  1. Server generates a cryptographically strong nonce per response.
  2. Vite uses a placeholder nonce in vite.config.ts:
    html: { cspNonce: '__CSP_NONCE__' }
  3. Server replaces __CSP_NONCE__ with the real nonce in the HTML.
  4. Server sets CSP:
    script-src 'self' 'nonce-<nonce>';
    style-src 'self' 'nonce-<nonce>';
    

Example Server (Node.js + Express):

const app = express();

const dist = path.resolve("dist");

// Generate unique nonce per-request
app.use(async (req, res, next) => {
	res.locals.nonce = crypto.randomUUID();
	next();
});

// Add CSP header to responses, including nonce
app.use((req, res, next) => {
	const directives = {
		"default-src": ["'self'"],
		"script-src": ["'self'", () => `'nonce-${res.locals.nonce}'`],
		"style-src-elem": ["'self'", () => `'nonce-${res.locals.nonce}'`],
		"style-src": ["'self'", () => `'nonce-${res.locals.nonce}'`],
	};
	const middleware = helmet({
		contentSecurityPolicy: { useDefaults: false, directives },
	});
	return middleware(req, res, next);
});

// Return index.html for root path, replacing __CSP_NONCE__ with the current nonce value
app.get("/", async (req, res) => {
	let html = await fs.readFile(path.join(dist, "index.html"), "utf8");
	res.setHeader("Content-Type", "text/html; charset=utf-8");
	res.end(html.replaceAll("__CSP_NONCE__", res.locals.nonce));
});

// Static assets (no nonce needed for external .js/.css files)
app.use(express.static(dist, { fallthrough: true }));

app.listen(8080);

Never reuse nonces and never cache HTML with static nonces.
If you use a CDN, inject nonces dynamically at the edge or bypass caching for HTML.

Step 4: Example Strict CSP Header (Production)

Content-Security-Policy:
  default-src 'none';
  base-uri 'self';
  frame-ancestors 'none';
  script-src 'self' 'nonce-<NONCE>';
  style-src 'self' 'nonce-<NONCE>';
  img-src 'self' data:;
  font-src 'self';
  connect-src 'self' https://api.example.com;
  object-src 'none';
  form-action 'self';
  upgrade-insecure-requests;

Step 5: Development Mode Exceptions

Directive Dev Allowance Why Needed
connect-src ws: wss: Vite HMR WebSocket
script-src 'unsafe-eval' (if needed) Framework source maps

These should only be included in a production CSP when absolutely necessary.

Step 6: Testing Your CSP

  1. Start in Report-Only mode:
    Content-Security-Policy-Report-Only: ...
    
  2. Open DevTools Console → watch for CSP violation logs.
  3. Verify SRI in browser network tab (check <script>/<link> integrity attr).
  4. Try injecting inline <script> in console — it should fail.

Final Vite Config Example

// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react-swc';
import sri from 'vite-plugin-sri-gen';

export default defineConfig(({ mode }) => ({
  server: {
    host: '::',
    port: 3001,
    hmr: { host: 'localhost', port: 3001, protocol: 'ws' },
  },
  plugins: [react(), sri()],
  html: { cspNonce: '__CSP_NONCE__' },
  build: {
    cssCodeSplit: false,
    assetsInlineLimit: 0,
  },
}));

Quick Checklist

  • ✅ Use SRI for all scripts/styles (vite-plugin-sri-gen).
  • ✅ Avoid inline scripts/styles; if unavoidable, use per-request nonces.
  • ✅ Set html.cspNonce and replace with a secure server-generated nonce.
  • ✅ Keep assetsInlineLimit: 0; avoid broad data: allowances.
  • ✅ No dangerouslySetInnerHTML without sanitization.
  • ✅ Lock down default-src, connect-src, frame-ancestors.
  • ✅ Test in Report-Only mode before enforcing.

With these steps, your Vite SPA can run under a strong, modern, and enforceable CSP without breaking dev productivity or runtime reliability.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment