Most of the live Lovable and Replit apps we checked ship without two security headers that take a few lines to add: a Content-Security-Policy and protection against being framed by another site. This is how I add both to a Vite or React app on Vercel, Netlify or Lovable's own hosting, without breaking it, in three steps: a starting policy, a report-only trial, then enforcement.
Why this matters for AI-built apps
The State of AI-Built Apps 2026 report put numbers on it. "On 18 October 2026 we ran a passive check on every app listed in two public showcases: 533 from madewithlovable.com and 224 from the Replit community showcase. 578 of the 757 were still online." The biggest gap: "Security headers are the most common gap. 93% of the live Lovable apps and 79% of the live Replit apps send no Content-Security-Policy, and 91% and 72% have no protection against being loaded inside another site's frame."
A missing header breaks nothing you can see, which is why it's so easy to ship without one. And a Lovable or Vite build is a folder of static files, so no change to your React code adds these headers. They come from whatever serves the files.
Two headers close most of that gap:
-
Content-Security-Policy(CSP) tells the browser which sources your page may load scripts, styles, images and network requests from. MDN's CSP guide explains it as a defence against cross-site scripting: injected code that runs as if it were your own. -
Framing protection. The CSP
frame-ancestorsdirective controls which sites may embed your page in an iframe. MDN calls it "an effective protection against clickjacking attacks" and says that unless you need your site to be embeddable, you should set it to'none'.X-Frame-Options: DENYis the older header that does the same job in older browsers (MDN: X-Frame-Options).
Step 1: write a starting policy
Before writing anything, open developer tools on your live site, go to the Network tab, reload and use the main features. Write down every domain the app talks to: your Supabase project, font hosts, analytics, payments, image hosts. Those go in the policy.
For a typical Vite + React app on Supabase, I start here. Replace YOUR-PROJECT with your Supabase project reference:
default-src 'self';
script-src 'self';
style-src 'self' 'unsafe-inline';
img-src 'self' data: blob: https:;
font-src 'self' data:;
connect-src 'self' https://YOUR-PROJECT.supabase.co wss://YOUR-PROJECT.supabase.co;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
object-src 'none'
What each line is doing:
-
default-src 'self': anything not listed below may only come from your own domain. -
script-src 'self': scripts only from your own files. MDN warns against'unsafe-inline'for scripts because "it defeats much of the purpose of having a CSP". -
style-src 'self' 'unsafe-inline': many UI libraries add style tags at runtime and stop working without'unsafe-inline'. Inline styles carry far less risk than inline scripts, which this policy still blocks. If your app works without it in report-only mode, remove it. -
img-srcandfont-src: Vite inlines small images and fonts asdata:URIs, so allowdata:there. Never allowdata:inscript-src. -
connect-src: wherefetchand WebSocket calls may go. Your Supabase API ishttps://<ref>.supabase.co, and Supabase Realtime connects overwss://<ref>.supabase.co/realtime/v1/websocket(Supabase: Realtime protocol). MDN notes that'self'does not cover WebSocket schemes in every browser (MDN: connect-src), so list thewss://host explicitly. -
frame-ancestors 'none': nobody may frame your app. If you embed your own pages, use'self'instead. -
base-uri 'self',form-action 'self'andobject-src 'none': an injected<base>tag can't change where relative URLs point, forms only submit to your own domain, and<object>/<embed>are blocked.
Then add each other domain from your Network list to the matching rule. Google Fonts, for example, needs https://fonts.googleapis.com in style-src and https://fonts.gstatic.com in font-src. Payment and analytics providers publish the domains their scripts need. Add only the hosts you actually use.
Step 2: ship it in report-only mode first
A wrong policy breaks your app, so don't enforce it on day one. The Content-Security-Policy-Report-Only header (MDN reference) exists for exactly this: in MDN's words, "The policy is not enforced, but any violations are sent to the reporting endpoint." Deploy the report-only version and use every screen for a few days with the browser console open: sign in, save data, upload, pay, and anything realtime. Each report names the rule and the blocked address. Each one is either a host you forgot or something you don't want loading at all.
On Vercel, add a headers block to vercel.json in the project root and redeploy. The /(.*) source matches every path, as in Vercel's own example. If the file already has the rewrite that serves index.html for your app routes, keep both keys in the same file. Each header value must stay on one line:
{
"rewrites": [{ "source": "/(.*)", "destination": "/index.html" }],
"headers": [
{
"source": "/(.*)",
"headers": [
{
"key": "Content-Security-Policy-Report-Only",
"value": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self' https://YOUR-PROJECT.supabase.co wss://YOUR-PROJECT.supabase.co; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'"
},
{ "key": "X-Frame-Options", "value": "DENY" }
]
}
]
}
On Netlify, create a _headers file. It has to end up in the folder you deploy (Netlify: custom headers). In a Vite project, files in public/ are "copied to the root of the dist directory as-is" (Vite: the public directory), so put it at public/_headers, with each header on its own indented line under the path:
/*
Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self' https://YOUR-PROJECT.supabase.co wss://YOUR-PROJECT.supabase.co; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'
X-Frame-Options: DENY
Netlify's docs add one limit worth knowing: custom headers aren't applied to proxied content or to URLs handled by a function or edge function, such as server-side rendered pages.
On Lovable's own hosting, put the headers in front of the app or host the build yourself. If your domain already runs through Cloudflare, a Response Header Transform Rule can set the same headers on every response. Otherwise, Lovable's Git sync keeps the code in a repository you own so you can "deploy anywhere" (Lovable: Git sync), for example with the Vercel or Netlify setup above. Either way, confirm with curl -I https://your-domain.com that the headers reach your custom domain.
Step 3: enforce it
When the console stays clean, rename the header from Content-Security-Policy-Report-Only to Content-Security-Policy and deploy again. Keep X-Frame-Options: DENY next to it for older browsers; it costs nothing.
Then test the important flows once more: sign-in, data loading, file upload, checkout and anything realtime. A quick framing test helps too: a page on another origin that loads your site in an iframe should show the frame blocked.
What if my host won't let me set headers?
MDN says you can also deliver a CSP in a <meta http-equiv="Content-Security-Policy"> tag, and that this suits "a client-side-rendered single page app which has only static resources". It has two catches. A report-only policy cannot be delivered in a <meta> element (MDN: CSP guide), and frame-ancestors is not supported in <meta> (MDN: frame-ancestors). Setting X-Frame-Options in a meta tag "has no effect" (MDN: X-Frame-Options). So a meta tag can restrict scripts and requests, but clickjacking protection needs a real response header.
Common mistakes I'd avoid
-
Adding
'unsafe-inline'or'unsafe-eval'toscript-src, or a bare*anywhere, to make an error go away. That switches most of the protection off. Find the inline script and move it into a file instead. -
Forgetting the
wss://host. The browser blocks the Realtime connection. -
Setting
frame-ancestors 'none'when you really do embed the app, for example inside your own marketing site. List that site's origin instead of'none'. - Only checking the home page. Policies break on the screens that load third-party code: payments, maps, support chat.
-
Adding a new service without updating the policy. When you add analytics, chat or payments, add its domains to the policy in the same change, and keep
vercel.jsonorpublic/_headersin the repository so every deploy carries the headers.
Check it from outside
Once it's live, run curl -I on your home page and on a deep link and look for content-security-policy and x-frame-options. Or run the free Launch Check on your URL: it checks exposed keys, security headers, what Google and AI assistants see, speed and accessibility, in about a minute, with no sign-up.
I wrote this with AI assistance and reviewed it myself.
Top comments (0)