Lovable Users Can See Each Other's Data: How to Lock It Down
If one user can see another user's records in your Lovable app, the cause is almost always Row-Level Security: it is either disabled on the table, or enabled with a policy that does not filter by the logged-in user. With RLS off, every authenticated request reads the whole table through the public anon key. This guide shows how to confirm it with SQL and fix it correctly.
By Founder Name · Last verified: 2026-06-25
Why can my Lovable users see each other's data?
Because Row-Level Security (RLS) is not doing its job. Lovable apps talk to Supabase Postgres directly from the browser using the public anon key. The only thing standing between a logged-in user and every row in a table is an RLS policy. If RLS is off, or the policy has no user filter, any authenticated request returns the entire table — including other people's records.
There are two distinct failure modes, and they look identical to a user. First: RLS is disabled on the table entirely, so Postgres applies no row filtering at all. Second: RLS is enabled but the policy is permissive — something like USING (true) — which Lovable sometimes generates to make a feature work quickly during prototyping. Both mean a SELECT returns rows that belong to other accounts.
This is the single most common security gap in vibe-coded apps. The AI optimizes for the feature working in the editor preview, where you are the only user, so a missing or wide-open policy never surfaces. The hole only becomes visible once a second real account exists in production — which is exactly when it is most dangerous.
Related: Lovable RLS and auth best practices · Supabase RLS permissions in Lovable
What is Row-Level Security and why does Lovable need it?
Row-Level Security is a Postgres feature that filters which rows each request can see or change, based on the logged-in user. In a normal backend, your server code enforces access. Lovable apps have no server in front of the database — the browser queries Supabase directly. RLS is the access control. Without it, the anon key can read and write every row.
Think of it as a WHERE clause that Postgres adds to every query automatically, no matter what the client sends. A correct policy says: only return rows where the row's user_id matches the ID of the currently authenticated user, exposed by Supabase as auth.uid(). The client cannot bypass it, spoof it, or remove it, because the check runs inside the database engine, not in your JavaScript.
This is why a Lovable app can feel finished and still be wide open. The login screen works, the dashboard loads, the data saves — but authentication only proves who you are. Authorization, which decides what you are allowed to see, is a separate layer. RLS is that layer, and it is off by default on any table you or the AI created without explicitly enabling it.
How do I check if RLS is enabled on my tables?
Run one SQL query in the Supabase SQL Editor. It lists every table in your public schema and whether RLS is switched on. Any table holding user-specific data that shows rls_enabled = false is exposed: every authenticated request can read all of its rows. This is the first thing to check and it takes under a minute.
Open your Supabase project, go to the SQL Editor, paste the query below, and run it. You want rls_enabled to read true for every table that contains data scoped to a user — profiles, orders, messages, documents, anything personal.
- In the Lovable editor, open the Supabase integration and click through to your Supabase project dashboard.
- Open the SQL Editor from the left sidebar and start a new query.
- Paste and run: SELECT relname AS table_name, relrowsecurity AS rls_enabled FROM pg_class WHERE relkind = 'r' AND relnamespace = 'public'::regnamespace ORDER BY relname;
- Review the results: any user-data table with rls_enabled = false is exposed and must be fixed before the app stays in production.
- For tables that show true, continue to the policy check below — enabled RLS with a permissive policy is still wide open.
| What you see | What it means | Action |
|---|---|---|
| rls_enabled = false on a user-data table | No row filtering at all; anon key reads every row | Enable RLS immediately, then add a user-scoped policy |
| rls_enabled = true, no policies exist | RLS on but default-deny; queries return zero rows | Add a SELECT policy filtering on auth.uid() |
| rls_enabled = true, policy USING (true) | Policy is permissive; every authenticated user sees all rows | Replace with a policy scoped to auth.uid() |
| rls_enabled = true, policy USING (auth.uid() = user_id) | Correctly scoped per user | Verify write policies (INSERT/UPDATE/DELETE) too |
| Table has no user_id / owner column | Cannot scope rows to a user as designed | Add an owner column with a default of auth.uid(), backfill, then policy |
How do I see which policies are actually applied?
Enabling RLS without writing a correct policy is half the work — and a common trap. A table with RLS on but a USING (true) policy is exactly as exposed as one with RLS off. Run a second query against pg_policies to read the real condition behind each policy. You are looking for an auth.uid() filter, not a blanket true.
Paste this into the SQL Editor: SELECT tablename, policyname, cmd, qual AS using_expression, with_check FROM pg_policies WHERE schemaname = 'public' ORDER BY tablename, cmd;
The qual column is the USING expression that controls reads, and with_check controls inserts and updates. If qual reads true (or is empty) for a SELECT policy on a user-data table, every logged-in user can read every row regardless of who owns it. A correct row-owner policy shows something like (auth.uid() = user_id) instead.
How do I enable RLS and write a correct policy?
Enable RLS on the table, then add policies that filter on auth.uid() for each operation you allow. The pattern below scopes every row to its owner: users can only read, update, and delete rows where the user_id column matches their authenticated ID, and can only insert rows they own. Replace your_table and user_id with your actual names.
Run this in the Supabase SQL Editor. It is idempotent-friendly: it drops any existing same-named policy first so you can re-run it safely while iterating. Do this on a backup or a staging copy first if the table holds live data.
ALTER TABLE public.your_table ENABLE ROW LEVEL SECURITY; DROP POLICY IF EXISTS "Users read own rows" ON public.your_table; CREATE POLICY "Users read own rows" ON public.your_table FOR SELECT TO authenticated USING (auth.uid() = user_id); DROP POLICY IF EXISTS "Users insert own rows" ON public.your_table; CREATE POLICY "Users insert own rows" ON public.your_table FOR INSERT TO authenticated WITH CHECK (auth.uid() = user_id); DROP POLICY IF EXISTS "Users update own rows" ON public.your_table; CREATE POLICY "Users update own rows" ON public.your_table FOR UPDATE TO authenticated USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id); DROP POLICY IF EXISTS "Users delete own rows" ON public.your_table; CREATE POLICY "Users delete own rows" ON public.your_table FOR DELETE TO authenticated USING (auth.uid() = user_id);
Two prerequisites. The table must have a user_id column (uuid) that stores the owner; if it does not, add one with a default of auth.uid() and backfill existing rows before enabling RLS, or every old row becomes orphaned and invisible. And confirm reads are scoped to the authenticated role, not anon, so the policy is enforced against logged-in sessions.
- Confirm the table has a user_id (uuid) column that holds the row owner; add and backfill one if missing.
- Run ALTER TABLE ... ENABLE ROW LEVEL SECURITY to switch on filtering (this default-denies until policies exist).
- Add a SELECT policy with USING (auth.uid() = user_id) so users read only their own rows.
- Add INSERT, UPDATE, and DELETE policies with the matching auth.uid() = user_id check.
- Re-run the pg_policies query to confirm each policy shows the auth.uid() condition, not true.
Why did Lovable generate a policy that lets everyone see everything?
Because the AI optimizes for the feature working, not for least-privilege access. When a query returns nothing after you enable RLS, it is tempting — for the AI and the builder — to write USING (true) to make the data reappear. That fixes the symptom and reopens the hole. The right fix is a policy scoped to auth.uid(), not a blanket allow.
This is a textbook case of false-fixed hallucination applied to security: Lovable reports the issue resolved because the data is now visible again, when in reality it has simply removed the access control. The feature looks correct in the single-user editor preview, so nothing flags the regression. The exposure only manifests with a second real account in production.
It also overlaps with context rot at file 6-7: once the AI has touched several files in a session, it loses track of which tables already had correct policies and may overwrite a scoped policy with a permissive one while fixing an unrelated bug. Treat every RLS change as security-critical and verify it with the pg_policies query rather than trusting a fixed message in chat.
How do I verify the data leak is actually closed?
Test with two real accounts, not one. A fix that works when you are the only user proves nothing — the whole point of RLS is to separate accounts. Create a second user, sign in as each, and confirm each one sees only their own rows in the deployed build. Then attempt a cross-account read to prove the policy holds.
The decisive test is adversarial: while signed in as user A, try to fetch a row you know belongs to user B by its ID. With a correct policy, the query returns nothing, not an error and not the row. If user B's data comes back, the policy is still permissive and you are not done.
- Create two test accounts (User A and User B) in the deployed build, each with at least one record.
- Sign in as User A and confirm the list shows only User A's rows, never User B's.
- In the browser DevTools console, run a query for a known User B row ID while signed in as User A; confirm it returns no rows.
- Repeat the cross-account read for UPDATE and DELETE intent, not just SELECT, to confirm writes are scoped too.
- Run the pg_policies query one final time and confirm every user-data table has auth.uid()-scoped policies for all four commands.
When should I get a security audit instead of fixing it myself?
If real user data was readable in production, fixing one table is not enough — you need to know whether other tables, storage buckets, and edge functions have the same gap. A senior engineer audits every table's RLS state, every policy condition, storage access rules, and exposed keys, then leaves you with a written report of what was open and what is now closed.
Self-fixing is reasonable when you have a small number of tables, a clear user_id column on each, and you can run the verification above with two accounts. Escalate when the schema is large, when policies reference each other (which can cause infinite recursion errors), or when sensitive data — payments, health, personal identifiers — was exposed and you need a documented remediation for users or compliance.
A full audit also catches the adjacent gaps that cause the same leak by a different route: secrets hard-coded in the client bundle, service-role keys reachable from the browser, and public storage buckets. RLS is the most common hole, but it is rarely the only one in a vibe-coded app.
Related: Lovable Security Audit service · Is your Lovable app secure? Checklist
Frequently asked questions
Why can one user see another user's data in my Lovable app?
How do I check if Row-Level Security is enabled in Supabase?
Is enabling RLS enough to stop the data leak?
What does USING (auth.uid() = user_id) actually do?
My app worked fine in the Lovable preview — why is it leaking in production?
Is the Supabase anon key safe to ship in my client code?
Can users edit or delete each other's data, not just view it?
Will adding RLS break my existing Lovable app?
Lovable keeps regenerating a policy that exposes all rows. How do I stop it?
How do I know if other tables have the same problem?
App down or leaking data? Get an expert on it within 24–48h.
Book a free 30-minute audit call. We'll diagnose what's wrong and tell you exactly what it costs to fix.