What a Lovable app ships with
- A Vite + React single-page app: one JavaScript bundle holds every screen, so anything in it is public.
- Supabase for sign-in and data, with the anon key baked into that bundle. Supabase designed it to be public.
- Every VITE_ variable copied into the bundle at build time. VITE_OPENAI_API_KEY means "give this key to every visitor".
- Hosting on *.lovable.app, which does not let you set response headers, and an optional GitHub repo you can deploy elsewhere.
The five things that go wrong
- A table without Row Level Security. Lovable usually writes policies when it creates a table, but a table added in a hurry, a quick "just make it work" edit, or a table created in the Supabase dashboard often has none. With RLS off, the anon key in your page source reads (and often writes) every row. This is the single most common way a Lovable app leaks its whole user list.
- A paid API key with a VITE_ prefix. Chat, image, email, and payment keys asked for in the Lovable prompt tend to land in .env as VITE_SOMETHING. They are then in the bundle, and someone will find them and run up the bill.
- The service-role key in the client. When RLS gets in the way, the tempting fix is to use the service-role key. It bypasses RLS entirely and must never leave a server.
- No security headers. Lovable hosting sends no Content-Security-Policy, no HSTS, no clickjacking protection, and cannot be told to. Fine for a prototype, not for an app with paying users.
- A published .env. Connecting the project to GitHub and pushing before .env is ignored leaks every key in it, and the history keeps a copy even after you delete the file.
How to fix them
Turn on RLS and write one policy per table
alter table public.profiles enable row level security;
create policy "profiles: own rows" on public.profiles
for all
using (auth.uid() = user_id)
with check (auth.uid() = user_id);
-- every table must show true:
select tablename, rowsecurity from pg_tables where schemaname = 'public';Move paid keys behind a Supabase Edge Function
// supabase/functions/ask/index.ts (supabase secrets set OPENAI_API_KEY=sk-...)
Deno.serve(async (req) => {
const { prompt } = await req.json();
const r = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: { Authorization: `Bearer ${Deno.env.get("OPENAI_API_KEY")}`, "Content-Type": "application/json" },
body: JSON.stringify({ model: "gpt-4o-mini", messages: [{ role: "user", content: prompt }] }),
});
return new Response(await r.text(), { headers: { "Content-Type": "application/json" } });
});
// in the app: supabase.functions.invoke("ask", { body: { prompt } })Get security headers by deploying the same code on Netlify or Vercel
public/_headers (Netlify)
/*
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Every finding in a scan comes with the exact lines for your host and framework, opened for the one it detected.
Questions people ask
- Is the Supabase anon key in my page a leak?
- No. It is meant to be public. Row Level Security decides what that key can read. The scan tests exactly that, with a count-only request that never reads a row.
- Lovable wrote RLS policies for me. Am I done?
- Usually for the tables it created at the start. Check every table added since, and any created in the Supabase dashboard: new tables start with RLS off.
- Does the scan change anything in my app?
- No. It makes the same requests a browser makes, plus a few well-known paths like /.env to confirm they are not there. No writes, no logins, no user records.
Check your Lovable app now
Free, read-only, ten seconds. Then, if you want it watched: weekly re-scans, uptime and error alerts, code checks on the repo.
Scan your app free