You shipped it. Now lock it down.
In May 2025, Matt Palmer disclosed CVE-2025-48757: 170+ Lovable-built apps had Row Level Security disabled, exposing 303 database endpoints to unauthenticated reads. Personal debt records. Home addresses. API keys. All readable with a single curl command using the anon key sitting in every Lovable app's HTML source.
None of those builders did anything wrong on purpose. Lovable's AI creates Supabase tables through the chat flow and doesn't enable RLS. It wires the anon key into the client bundle and calls it done. The app works. The defaults look fine. They aren't.
This checklist fixes that. Seven steps, real commands, about an hour. Work through it top to bottom and you'll ship something that won't end up in a disclosure report.
Why Lovable apps keep showing up in disclosures
Every Lovable app ships on the same stack: React + Vite frontend, Supabase backend, deployed to yourproject.lovable.app or a custom domain. The stack is solid. The defaults are the problem.
Five things happen on almost every Lovable project:
- The AI creates Supabase tables through the chat flow and doesn't enable RLS unless you explicitly ask for it.
- The anon key and project URL land in the client bundle (fine — that's what the anon key is for) but so does anything else you pasted into the chat, including keys you intended to keep private.
- When RLS is added, the AI frequently writes
USING (true)as a placeholder — functionally identical to no policy at all. - Edge Functions are generated with
verify_jwt = falseinconfig.toml, meaning anyone on the internet can call them without a session. - Server-side code that uses the service role key bypasses RLS entirely — and the ownership check that should replace it never gets written.
Across Lovable apps we've scanned, almost every critical finding traces back to one of those five patterns.
Step 1: Enable Row Level Security on every table
Time: 10 minutes | Impact: Critical
This is the single most important step. For a deep dive on how RLS works and why AI skips it, see our full RLS guide. Open your Supabase dashboard, go to the SQL Editor, and run this to see where you stand:
SELECT tablename, rowsecurity
FROM pg_tables
WHERE schemaname = 'public'
ORDER BY tablename;
Every row where rowsecurity is false is a table anyone on the internet can read with just your anon key. That includes the profiles table Lovable auto-creates, any user_* tables, and the "example" tables the AI adds during prompting that you forgot to delete.
The Lovable gotcha to watch for: when you ask the AI to "add RLS," it will often generate a policy like USING (true) as a placeholder, which is functionally identical to no RLS at all. Run this to find those:
SELECT schemaname, tablename, policyname, qual
FROM pg_policies
WHERE schemaname = 'public'
AND (qual = 'true' OR qual IS NULL);
Any row that comes back is a policy that lets anyone read everything. Rewrite those using the examples below.
Enable RLS
-- Replace with your actual table names
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE items ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;
-- Repeat for EVERY table
Create basic policies
For most tables in a Lovable app, you want "users can only access their own data." The user ID column is typically user_id or created_by:
-- Profiles: users read and update their own profile
CREATE POLICY "Users manage own profile"
ON profiles FOR ALL
USING (auth.uid() = id);
-- User-owned data: full CRUD on own rows
CREATE POLICY "Users manage own items"
ON items FOR ALL
USING (auth.uid() = user_id);
-- For INSERT, also restrict which user_id can be set
CREATE POLICY "Users create own items"
ON items FOR INSERT
WITH CHECK (auth.uid() = user_id);
If your app has shared or public data (like published listings), add a separate read policy:
CREATE POLICY "Anyone can view published listings"
ON listings FOR SELECT
USING (status = 'published');
Verify it worked
Open an incognito browser window, go to your app, and check that unauthenticated users can't see data they shouldn't. You can also test directly:
curl "https://YOUR-PROJECT.supabase.co/rest/v1/profiles?select=*" \
-H "apikey: YOUR_ANON_KEY" \
-H "Authorization: Bearer YOUR_ANON_KEY"
This should return an empty array or an error — not your users' data.
Step 2: Find leaked secrets in your bundle
Time: 10 minutes | Impact: Critical
Lovable is a Vite app. Anything Vite ships to the browser is readable by anyone who opens DevTools. If you ever told the AI "use my OpenAI key, it's sk-..." or pasted a Stripe secret into the chat, there's a real chance that key is sitting in your production bundle right now. For the full breakdown, see Your .env Is Showing.
Check what's in your bundle
- Open your deployed app (
yourproject.lovable.appor your custom domain) in Chrome - Open DevTools (F12) → Sources tab → expand the top-level folder
- Search across all files (Ctrl+Shift+F) for these patterns:
sk-(OpenAI / Anthropic keys)sk_liveorrk_live(Stripe secret / restricted keys)service_roleSUPABASE_SERVICEorSERVICE_ROLEsecretorSECRETxoxb-(Slack bot tokens)AKIA(AWS access keys)
Or run a one-liner from your terminal that fetches the deployed JS and greps for common key prefixes:
curl -s https://yourproject.lovable.app/ \
| grep -oE '"/assets/[^"]+\.js"' \
| tr -d '"' \
| xargs -I{} curl -s "https://yourproject.lovable.app{}" \
| grep -oE '(sk-[a-zA-Z0-9]{20,}|sk_live_[a-zA-Z0-9]{20,}|service_role)' \
| sort -u
If that command prints anything, stop and rotate those keys before continuing.
Safe to find in client code:
- Supabase anon key (starts with
eyJ...) — safe IF RLS is enabled - Supabase project URL
- Stripe publishable key (
pk_live_...)
Must NOT be in client code:
- OpenAI/Anthropic API keys
- Stripe secret key
- Supabase service role key
- Any password or secret token
If you find exposed secrets
- Rotate them immediately. Generate new keys from the provider's dashboard. The old ones are compromised.
- Move them server-side. Use Supabase Edge Functions or Next.js API routes to keep secrets on the server.
Example — moving an OpenAI call server-side with a Supabase Edge Function:
// supabase/functions/ai-chat/index.ts
import { serve } from "https://deno.land/std/http/server.ts";
serve(async (req) => {
const { message } = await req.json();
const response = 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-4",
messages: [{ role: "user", content: message }],
}),
}
);
const data = await response.json();
return new Response(JSON.stringify(data));
});
Step 3: Verify authentication is actually enforced
Time: 15 minutes | Impact: High
Lovable generates login pages, protected routes, and signup flows that look enforced. The catch: most of that enforcement lives in the React component. The data path underneath usually doesn't care whether you're logged in.
Test your protected pages
- Log out of your app.
- Paste a protected URL directly into a fresh incognito tab —
/dashboard,/settings,/account. - Each one should redirect to login. If the page flashes content for half a second before redirecting, your check is client-side only — the data was already fetched.
Test the data path, not just the UI
This is the step most builders skip. Open DevTools, go to the Network tab, and log in. Every request to your-project.supabase.co/rest/v1/... is your real API. Copy one as cURL and strip the Authorization header:
# Anon key only — what an unauthenticated attacker has
curl "https://YOUR-PROJECT.supabase.co/rest/v1/orders?select=*" \
-H "apikey: YOUR_ANON_KEY"
If that returns data, the UI "protection" is theatre. RLS (Step 1) is your real authentication boundary.
The Edge Function gotcha almost everyone misses
When the Lovable AI creates a Supabase Edge Function for you — a checkout handler, a webhook, an AI proxy — the default supabase/config.toml entry looks like this:
[functions.ai-chat]
verify_jwt = false
verify_jwt = false means anyone on the internet can POST to that function. No login, no session, no token. If that function calls OpenAI, someone can drain your API budget in an afternoon. If it writes to your database, they can write whatever they want.
Flip it on for every function that shouldn't be public:
[functions.ai-chat]
verify_jwt = true
Then redeploy. Inside the function, read the user from the verified JWT instead of trusting anything in the request body:
const authHeader = req.headers.get("Authorization")!;
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_ANON_KEY")!,
{ global: { headers: { Authorization: authHeader } } },
);
const { data: { user } } = await supabase.auth.getUser();
if (!user) return new Response("Unauthorized", { status: 401 });
Step 4: Test for IDOR
Time: 10 minutes | Impact: High
IDOR — Insecure Direct Object Reference — is the "change the ID in the URL and see someone else's data" bug. It's the single most common finding in our scans of Lovable apps, because the AI loves to pass raw database IDs through the client.
The test
- Create two accounts: A and B.
- Log in as A. Create something — an order, a note, an invoice. Watch the Network tab as you click around and note the IDs in the URLs and request payloads.
- Log in as B in a different browser profile.
- Replace B's IDs with A's. Hit the endpoint. Reload the page.
If B can read A's data, you have an IDOR.
Fix it — the Lovable way
Most Lovable apps call Supabase directly from the client using hooks the AI scaffolded. That means your "ownership check" has to live in your RLS policy, not in application code:
-- Only the owner can read, update, or delete their orders
CREATE POLICY "Owner-only access to orders"
ON orders FOR ALL
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);
With that policy in place, supabase.from('orders').select('*').eq('id', someId) silently returns an empty array when someId belongs to another user. Try it in the Network tab — you should see a 200 with [], not the row.
If you wrote server-side routes or Edge Functions that use the service role key, RLS does not apply. You have to check ownership yourself:
// Inside an Edge Function using the service role client
const { data: order } = await supabase
.from("orders")
.select("*")
.eq("id", orderId)
.eq("user_id", user.id) // Must check yourself — RLS is bypassed
.single();
This is the most common IDOR pattern in Lovable apps: the AI scaffolds an Edge Function with the service role key (so it can "do admin things"), and nobody adds the ownership check. The RLS policy you wrote in Step 1 does nothing here — service role bypasses it completely.
Step 5: Add security headers
Time: 5 minutes | Impact: Medium
Security headers tell browsers how to handle your page: don't embed it in an iframe, don't guess MIME types, only talk to you over HTTPS. Lovable's default preview domain (*.lovable.app) does not send any of them. If you've connected a custom domain through Vercel, Netlify, or Cloudflare Pages, you control them yourself. Pick your host below.
For Vercel deployment (vercel.json)
{
"headers": [
{
"source": "/(.*)",
"headers": [
{ "key": "X-Frame-Options", "value": "DENY" },
{ "key": "X-Content-Type-Options", "value": "nosniff" },
{
"key": "Referrer-Policy",
"value": "strict-origin-when-cross-origin"
},
{
"key": "Strict-Transport-Security",
"value": "max-age=31536000; includeSubDomains"
},
{
"key": "Permissions-Policy",
"value": "camera=(), microphone=(), geolocation=()"
}
]
}
]
}
For Netlify (_headers file)
/*
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Strict-Transport-Security: max-age=31536000; includeSubDomains
Permissions-Policy: camera=(), microphone=(), geolocation=()
Step 6: Lock down file uploads
Time: 5 minutes (if applicable) | Impact: Medium
If your Lovable app lets users upload files (images, documents, etc.), check that:
- File types are validated. Only accept the types you expect (e.g., images only: JPEG, PNG, WebP).
- File sizes are limited. Set a reasonable max (5MB for images, 10MB for documents).
- Files go to Supabase Storage, not your app server. Supabase Storage handles files safely in a separate domain.
- Storage bucket policies are set. In your Supabase dashboard, check that storage buckets have appropriate access policies.
The same Lovable RLS pattern applies here. By default, Supabase Storage buckets are created with no policies — meaning anyone with your anon key can list or download every file. Run this check in the SQL Editor:
SELECT bucket_id, name, owner
FROM storage.objects
LIMIT 20;
If that returns rows without authentication, your storage is open. Lock it down with an ownership policy:
-- Only the file owner can access their uploads
CREATE POLICY "Users access own uploads"
ON storage.objects FOR ALL
USING (auth.uid()::text = (storage.foldername(name))[1]);
Step 7: Turn on monitoring
Time: 5 minutes | Impact: Medium
Even after the checklist, you want to know the moment something weird happens — before your OpenAI bill hits four figures overnight.
Supabase logs
In your Supabase dashboard:
- Logs & Analytics > API Logs — scan for requests with unusually large response bodies or 200 responses to tables that should require auth. A spike in
/rest/v1/profiles?select=*is the telltale sign of someone pulling the whole table. - Logs & Analytics > Auth Logs — watch for bursts of failed logins from the same IP, or a flood of signups right after you tweet your launch.
- Database > Roles — confirm only
authenticatedandanonhave access to your public schema. If you seePUBLICwith broad grants, revoke them.
Key abuse
If you call paid APIs, set hard ceilings before you set "alerts":
- OpenAI / Anthropic: set a monthly hard limit (not just soft alert) in your billing settings. $50 is plenty for a launch day.
- Stripe: enable Radar rules for high-volume card testing and watch
charge.failedwebhooks. - Supabase Auth: turn on email confirmation so bot signups don't instantly burn your
SIGNUPS_PER_HOURquota.
The complete Lovable security checklist
Here's everything in one list. Work through it top to bottom:
- RLS enabled on every Supabase table
- RLS policies created for every table
- No secrets in client-side JavaScript (except anon key and publishable keys)
- All exposed secrets rotated
- Protected pages redirect to login when unauthenticated
- API endpoints return 401 without valid auth
- Users cannot access other users' data (no IDOR)
- Security headers configured on deployment
- File uploads validated for type and size (if applicable)
- Supabase Storage policies configured (if applicable)
- Usage alerts set on third-party APIs
- Supabase auth logs reviewed
What comes after the manual review
Work through this checklist and your app is already in better shape than most Lovable apps running in production today.
The catch: every new feature is a new chance to regress. The AI adds a table next week — no RLS. It scaffolds a new Edge Function — verify_jwt = false. You wire up a new service role call — no ownership check. The checklist is a one-time audit. Your app isn't a one-time project.
Manual reviews drift. Automated scanning doesn't.
Flowpatrol runs every check on this page — plus dozens more — against your live URL. Paste the URL, get a report in five minutes, fix what matters. Run it after every feature push, or wire it into CI so it catches regressions before they ship.
This guide is current as of March 2026. The Supabase steps apply to any app using Supabase, whether it was built by Lovable, Bolt, Cursor, or hand-written.