To secure a Lovable + Supabase stack, work through four things in order: (1) read every RLS policy and check what it grants the authenticated role, not just that RLS is on, (2) confirm the service role key is in an Edge Function secret and not a VITE_ variable, (3) check the auth flow handles the loading state before it renders a protected route, and (4) turn on the Supabase Auth settings that Lovable cannot turn on for you.
TL;DR
Lovable enables Row Level Security by default and lints for tables that miss it. The leaks happen one step later: a policy like USING (auth.uid() IS NOT NULL) has RLS on, has a policy, and isn't always-true, so it passes every check Supabase and Lovable run. It also returns every row in the table to anyone who signs up. RLS on is not RLS correct.
The gap between "RLS is on" and "RLS works"
Lovable's founder security guide is direct about this: it "enables RLS by default and includes a linter that catches missing RLS enablement after database migrations." Supabase's Security Advisor backs that up with three lints:
| Lint | What it catches |
|---|---|
| 0013 | Table publicly accessible. RLS is not enabled, so anyone with your project URL can read and write the table. |
| 0008 | No access rules defined. RLS is on but no policy exists, so nothing reads or writes through the API. |
| 0024 | Security policy allows unrestricted access. A policy uses an always-true condition like USING (true). |
Those cover the three obvious failures. Now look at the policy an AI tool writes when you ask it to "make sure only logged-in users can see their data":
CREATE POLICY "Authenticated users can read"
ON orders FOR SELECT
TO authenticated
USING (auth.uid() IS NOT NULL);
RLS is enabled, so 0013 is quiet. A policy exists, so 0008 is quiet. The condition isn't true, so 0024 is quiet. Every advisor is green.
And any person who signs up gets every row in orders, including yours.
auth.uid() IS NOT NULL means "is anyone logged in", not "is this row theirs". On a public-signup app that's the same as no policy at all. This is the single most common finding we see on Lovable + Supabase scans, and it is invisible to the linters on both sides.
The fix is to compare the row to the caller:
DROP POLICY "Authenticated users can read" ON orders;
CREATE POLICY "Users read their own orders"
ON orders FOR SELECT
TO authenticated
USING ((SELECT auth.uid()) = user_id);
CREATE POLICY "Users create their own orders"
ON orders FOR INSERT
TO authenticated
WITH CHECK ((SELECT auth.uid()) = user_id);
Two details worth keeping. Wrapping auth.uid() in a SELECT lets Postgres evaluate it once per query instead of once per row, which matters as soon as the table gets big. And SELECT needs USING while INSERT needs WITH CHECK; a policy with only USING won't restrict what someone can write.
Part 1: Audit the policies you already have
Start by listing what's actually there rather than what you remember asking for.
-- Which tables have RLS off?
SELECT tablename, rowsecurity
FROM pg_tables
WHERE schemaname = 'public'
ORDER BY rowsecurity, tablename;
-- What does every policy actually say?
SELECT tablename, policyname, roles, cmd,
qual AS using_expression,
with_check AS check_expression
FROM pg_policies
WHERE schemaname = 'public'
ORDER BY tablename;
Read the using_expression column line by line. You're looking for anything that doesn't mention a column from the row itself. true, auth.uid() IS NOT NULL, auth.role() = 'authenticated', and EXISTS (SELECT 1 FROM profiles) are all table-wide reads wearing a policy's clothes.
Enabling RLS on a table with no policy silently breaks the feature. Postgres denies everything, and your app shows an empty list rather than an error. If a page went blank right after you "fixed security", that's lint 0008, not a bug in the UI.
Part 2: The key split
Supabase ships two kinds of key and they behave nothing alike.
The publishable key (the one still called anon in older projects) is meant to be in your bundle. Supabase's own docs list it as "Safe to expose online: web page, mobile or desktop app, GitHub actions, CLIs, source code." That safety is conditional, and the docs spell out the condition: enable RLS on all tables, and regularly review what your policies grant the anon and authenticated roles. So finding VITE_SUPABASE_ANON_KEY in your JavaScript bundle is not a finding. Finding it next to a table with a permissive policy is.
The secret key (service_role) is the opposite. It holds the Postgres BYPASSRLS attribute, which means it ignores every policy in this blueprint. Supabase's wording: "Never use in a browser, even on localhost."
# .env - shipped to the browser, Vite inlines these at build time
VITE_SUPABASE_URL=https://xxx.supabase.co
VITE_SUPABASE_ANON_KEY=eyJ...
# Edge Function secrets - never prefixed with VITE_, never in .env committed to git
# Set with: supabase secrets set SUPABASE_SERVICE_ROLE_KEY=eyJ...
SUPABASE_SERVICE_ROLE_KEY=eyJ...
The mistake worth checking for takes ten seconds. Any variable name starting with VITE_ is inlined into the JavaScript bundle at build time. If someone asked Lovable to "use the service role key so this admin page works", the fastest path was a VITE_ variable, and that key is now in a file every visitor downloads.
npm run build
grep -ro "service_role" dist/ | head
One hit is one too many. If you find one, rotating the key in the Supabase dashboard is the first move, not the last.
Part 3: The auth flow Lovable generates
The generated useAuth hook is usually fine. The bug is almost always in what consumes it.
import { useEffect, useState } from 'react';
import { supabase } from '@/lib/supabase';
export function useAuth() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
supabase.auth.getSession().then(({ data: { session } }) => {
setUser(session?.user ?? null);
setLoading(false);
});
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(_event, session) => {
setUser(session?.user ?? null);
}
);
return () => subscription.unsubscribe();
}, []);
return { user, loading };
}
getSession() is asynchronous. For the first frame or two after a page reload, user is null even for a signed-in person. A protected route that checks if (!user) return <Redirect /> will bounce a legitimate user to the login page on every refresh. Worse is the inverse: a dashboard that renders its content while loading is still true, flashing data before the redirect fires.
export function Protected({ children }) {
const { user, loading } = useAuth();
if (loading) return <Spinner />;
if (!user) return <Navigate to="/login" replace />;
return children;
}
None of this is a security control on its own. A client-side redirect only hides the UI; the actual protection is the RLS policy from Part 1. Treat the route guard as a UX fix and the policy as the security fix.
Part 4: The settings Lovable cannot set for you
Some of the important switches live in the Supabase dashboard, not in generated code, so no amount of prompting will turn them on.
Leaked password protection. Supabase Auth can check new passwords against the HaveIBeenPwned Pwned Passwords API and reject known-breached ones. It's available on the Pro plan and above, under Authentication settings. Nothing Lovable writes touches it.
Minimum password length. Supabase's guidance is plain: "Anything less than 8 characters is not recommended." Set it before you have users, because raising it later locks out everyone who already signed up with a shorter one.
Redirect URLs. Your production domain has to be in the allow list or magic links and OAuth callbacks fail after deploy. This is the single most common "it worked in preview and broke in production" report for this stack.
Email confirmations. Off means anyone can sign up as you@yourcompany.com and, if any policy keys off an email address, inherit whatever that address is trusted with.
Pre-launch checklist
If you want the wider Lovable picture rather than just the Supabase half, 7 common Lovable security risks covers the failures that live outside the database, and the Supabase security guide goes deeper on Postgres roles and storage policies.
Does Lovable enable RLS automatically?
Yes. Lovable's own founder security guide says it enables RLS by default and includes a linter that catches missing RLS enablement after database migrations. That is not the same thing as a correct policy, and the correct-policy half is where the leaks come from.
Is it safe for the Supabase anon key to be in my Lovable app's bundle?
Supabase lists the publishable (anon) key as safe to expose in a web page, mobile app, or source code. The safety is conditional: RLS enabled on all tables, and a regular review of what your policies grant the anon and authenticated roles. The key was never the secret. The policy is.
Where does the Supabase service role key belong in a Lovable app?
In an Edge Function secret. Never in a VITE_ variable, because Vite inlines those into the browser bundle at build time. The service role key holds the Postgres BYPASSRLS attribute, so it ignores every policy you wrote, and Supabase's docs say not to use it in a browser even on localhost.
Does the Supabase security advisor catch a bad RLS policy?
It catches three shapes: RLS off entirely (lint 0013), RLS on with no policy (0008), and a policy using an always-true condition like USING (true) (0024). A policy that checks auth.uid() IS NOT NULL passes all three, because RLS is on, a policy exists, and the condition is not literally true. It still returns every row to anyone who signs up.
Why does my Lovable app show an empty list after I enabled RLS?
That is lint 0008. Postgres denies everything when RLS is on and no policy matches, and the Supabase client returns an empty array rather than an error, so the UI just looks blank. Add a SELECT policy for the authenticated role that compares auth.uid() to the row's owner column.
Alternative Stack Options
Consider these related blueprints for different stack combinations:
- Lovable + Firebase - Alternative backend with Firestore
- Bolt + Supabase - Same backend, different AI tool
- Lovable + Vercel - Deployment platform guide
Built with Lovable + Supabase?
CheckYourVibe reads your live app the way a stranger would and flags the tables that answer.