Content Security Policy for a React + Vite app, without breaking it
How to write a working CSP for a React app built with Vite, Lovable or Bolt: find what the page loads, test in report-only mode, then enforce it.
· 7 min read
A header checker tells you "No Content-Security-Policy". You ask your AI assistant to add one, deploy, and the site turns into a white page with a console full of red: "Refused to load the script", "Refused to connect to", "Refused to apply inline style". So you delete the header again, or you add 'unsafe-inline' and * until the errors stop, which leaves a policy that blocks almost nothing.
A CSP for a React app built with Vite is not hard, but it has to describe what your page actually loads. Copy-pasted policies fail because they were written for someone else's page. Below: what a CSP does, why AI-built apps trip over it, how to find your real sources, and how to roll a policy out without an outage.
What a CSP does, and why it breaks apps
Content-Security-Policy is a response header that tells the browser where the page may load scripts, styles, images, fonts and network requests from. If an attacker manages to inject a <script> tag or an inline event handler, for example through a comment field rendered as HTML, the browser refuses to run it because it is not on the list. That is the main point: CSP limits the damage of cross-site scripting.
A few rules from the MDN reference for the header explain most of the breakage:
default-srcis the fallback. Any fetch directive you leave out, such asimg-srcorconnect-src, inherits it.default-src 'self'alone therefore blocks your Supabase API, your fonts from Google and your analytics.- A policy with
script-srcordefault-srcblocks inline scripts andeval()unless you allow them with'unsafe-inline'or'unsafe-eval'. Those two keywords are exactly what makes a policy weak. Content-Security-Policy-Report-Onlyreports without blocking. Same syntax, different header name. The browser logs every violation and keeps the site working. This is how you test.
The good news for Vite users: a production vite build of a plain React app has no inline scripts. The index.html it emits loads one module from /assets/, so script-src 'self' works out of the box. The breakage comes from everything around it.
Why AI-built apps trip over it
Apps from Lovable, Bolt, v0 or a Cursor session tend to load more third parties than their owners realise:
- The backend lives on another domain. A Supabase project is
https://<project>.supabase.co, and realtime subscriptions usewss://on the same host. Both need to be inconnect-src. Storage images from Supabase needimg-src. - Fonts and icons come from CDNs. Google Fonts needs
fonts.googleapis.cominstyle-srcandfonts.gstatic.cominfont-src. - Analytics and chat widgets load more scripts. A tag manager or support widget injects further scripts from its own domains, which you won't see in your HTML.
- Runtime style injection. Some toast, animation and CSS-in-JS libraries add
<style>elements at runtime. Without'unsafe-inline'instyle-src, those styles are refused. Styles set through JavaScript properties (element.style.width = ..., which is what React'sstyleprop does) are not affected. - Vite inlines small assets as
data:URIs. Small images and fonts belowbuild.assetsInlineLimitend up embedded in CSS or JS. The Vite guide on CSP says you can allowdata:forimg-srcandfont-src, or set the limit to0, and warns never to allowdata:inscript-src.
So the assistant writes a textbook policy, it misses three of these, and the app breaks.
Check it yourself
1. See what you send now
curl -sI https://yourdomain.com | grep -iE 'content-security-policy|x-frame-options'
No output means no policy in the header. Also check index.html for a <meta http-equiv="Content-Security-Policy"> tag; some templates add one. If a policy exists, look for 'unsafe-inline' or 'unsafe-eval' in script-src, and for * or https: as a source. Each of these lets most injected scripts through.
2. List what the page really loads
Open your live site in Chrome, open DevTools, go to the Network tab, tick "Disable cache" and reload. Then use the app: sign in, open the pages with images, trigger the features that call your backend. Sort by the Domain column. Every domain that is not yours belongs in some directive:
.jsfiles:script-src.cssfiles:style-src- fonts:
font-src - images:
img-src - Fetch/XHR and WS rows:
connect-src - iframes (payment forms, embedded video):
frame-src
Write the list down. This is your policy.
3. Test it in report-only mode
Build a first policy from the list. For a typical Lovable or Bolt app on Supabase with Google Fonts it looks like this (replace the project ref):
default-src 'self';
script-src 'self';
style-src 'self' https://fonts.googleapis.com;
font-src 'self' https://fonts.gstatic.com data:;
img-src 'self' data: https://YOUR-REF.supabase.co;
connect-src 'self' https://YOUR-REF.supabase.co wss://YOUR-REF.supabase.co;
frame-ancestors 'self';
base-uri 'self';
form-action 'self';
object-src 'none'
Send it on one line as Content-Security-Policy-Report-Only, deploy, and use the site again with the Console open. Each blocked resource shows a message naming the directive and the URL. Add what is legitimate, ask about anything you don't recognise, and repeat until a full walk through the app produces no reports.
Fix it: enforce the policy in the right place
Where the header goes depends on the host. On Vercel it is a headers entry in vercel.json; on Netlify, a _headers file in the publish folder; on your own server, the nginx or Caddy config. Apply it to all paths, since a single-page app serves index.html for every route.
If your host gives you no way to set headers, a <meta http-equiv="Content-Security-Policy"> tag in index.html is a fallback. It covers script and connection rules, but browsers ignore frame-ancestors, report-uri and sandbox in a meta tag, so clickjacking protection and reporting need a real header. There is no report-only version of the meta tag either, so test carefully before shipping it.
About style-src 'unsafe-inline': if a library you depend on injects styles and you can't replace it, allowing inline styles is a reasonable compromise. Injected CSS is far less dangerous than injected JavaScript. Keep script-src strict; that is the part that matters.
If you need an inline script, for example an analytics snippet, Vite can add a nonce to its tags with html.cspNonce, but the value must be unique per request, which a static host cannot do. On static hosting, move the snippet into a file and load it with src, or allow it by its hash.
A prompt for Lovable, Cursor or Claude Code:
Add a Content-Security-Policy to this React + Vite app.
1. List every external domain the app loads scripts, styles, fonts,
images, iframes and network requests from. Check the code, index.html,
environment variables (e.g. the Supabase URL) and third-party
widgets. Show me the list before changing anything.
2. Write a policy with default-src 'self', script-src 'self' plus only
the script domains we need, connect-src with our API and its wss://
address, frame-ancestors 'self', base-uri 'self', object-src 'none'.
Do not use 'unsafe-eval', wildcards or 'unsafe-inline' in script-src.
Allow data: only in img-src and font-src.
3. Set it as a Content-Security-Policy-Report-Only response header for
all paths, in the config file of our host (vercel.json, _headers or
the server config). Do not put it in a meta tag unless the host
cannot set headers.
4. Tell me which pages and actions to test, and what to change to switch
from Report-Only to Content-Security-Policy afterwards.
When the report-only run is clean for a few days of normal use, rename the header to Content-Security-Policy and deploy again.
Keep it from coming back
A policy is a list of today's dependencies. It goes stale the moment someone adds a chat widget, switches the image CDN or asks the assistant to "just make the console errors go away". The usual failure is not a missing policy but a policy that slowly gains 'unsafe-inline', 'unsafe-eval' and * one fix at a time, or a new hosting config that silently drops the header.
Two habits help. Treat changes to the CSP line like code changes: review the diff and ask why each new source is there. And recheck the live response after deploys that touch hosting config, with the curl command above. If you want a broader look at your site's basics first, the plain-English security check covers the rest of the headers.
ScanYourSite's security headers check does the recheck for you. The free check reads the headers your live page returns and reports a missing Content-Security-Policy, a policy weakened by 'unsafe-inline' or 'unsafe-eval', and pages that can be framed by another site. When the policy is missing, the report lists the external hosts your home page loads, grouped by directive, as a starting point for report-only mode. The fix comes with a prompt that says where your host sets headers. The free plan checks one site weekly; Pro checks daily and after every deploy, and alerts you right away when the header disappears or gets weaker.
To see what your site sends today: https://scanyoursite.app/?check=yourdomain.com