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.
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-inlineandunsafe-eval. - Restrict network calls via
connect-srcto reduce exfiltration risk. - Prevent clickjacking with
frame-ancestors. - Control navigation with
base-uri. - Get visibility into violations via
report-toorreport-uri.
-
Subresource Integrity (SRI):
Adds anintegrityattribute 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 anonceattribute 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.
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()],
});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") unlessstyle-src-attris allowed.
Best practices:
- Keep
index.htmlfree of inline scripts.
Usehtml.cspNonceso any unavoidable inline helpers get a nonce. - Disable asset inlining:
This avoids
build: { assetsInlineLimit: 0 }
data:URLs in scripts/styles, simplifying CSP. - Avoid
dangerouslySetInnerHTMLin 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 requiresstyle-src-attr(not recommended).
- Server generates a cryptographically strong nonce per response.
- Vite uses a placeholder nonce in
vite.config.ts:html: { cspNonce: '__CSP_NONCE__' }
- Server replaces
__CSP_NONCE__with the real nonce in the HTML. - 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.
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;
| 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.
- Start in Report-Only mode:
Content-Security-Policy-Report-Only: ... - Open DevTools Console → watch for CSP violation logs.
- Verify SRI in browser network tab (check
<script>/<link>integrity attr). - Try injecting inline
<script>in console — it should fail.
// 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,
},
}));- ✅ Use SRI for all scripts/styles (
vite-plugin-sri-gen). - ✅ Avoid inline scripts/styles; if unavoidable, use per-request nonces.
- ✅ Set
html.cspNonceand replace with a secure server-generated nonce. - ✅ Keep
assetsInlineLimit: 0; avoid broaddata:allowances. - ✅ No
dangerouslySetInnerHTMLwithout sanitization. - ✅ Lock down
default-src,connect-src,frame-ancestors. - ✅ Test in
Report-Onlymode before enforcing.
With these steps, your Vite SPA can run under a strong, modern, and enforceable CSP without breaking dev productivity or runtime reliability.