Hire Lovable Xperts
Security

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.

Do not keep this app live with real user data while the table is readable across accounts. The anon key is shipped in your client bundle and is visible to anyone — without correct RLS, treat every row in the table as already public.

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.

  1. In the Lovable editor, open the Supabase integration and click through to your Supabase project dashboard.
  2. Open the SQL Editor from the left sidebar and start a new query.
  3. 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;
  4. Review the results: any user-data table with rls_enabled = false is exposed and must be fixed before the app stays in production.
  5. For tables that show true, continue to the policy check below — enabled RLS with a permissive policy is still wide open.
RLS Exposure Diagnostic — How to Read the Results
What you seeWhat it meansAction
rls_enabled = false on a user-data tableNo row filtering at all; anon key reads every rowEnable RLS immediately, then add a user-scoped policy
rls_enabled = true, no policies existRLS on but default-deny; queries return zero rowsAdd a SELECT policy filtering on auth.uid()
rls_enabled = true, policy USING (true)Policy is permissive; every authenticated user sees all rowsReplace with a policy scoped to auth.uid()
rls_enabled = true, policy USING (auth.uid() = user_id)Correctly scoped per userVerify write policies (INSERT/UPDATE/DELETE) too
Table has no user_id / owner columnCannot scope rows to a user as designedAdd 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.

A frequent variant: read access is correctly scoped but UPDATE and DELETE policies are missing or permissive, so users can edit or delete each other's rows even though they cannot list them in the UI. Always check all four commands — SELECT, INSERT, UPDATE, DELETE — not just SELECT.

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.

  1. Confirm the table has a user_id (uuid) column that holds the row owner; add and backfill one if missing.
  2. Run ALTER TABLE ... ENABLE ROW LEVEL SECURITY to switch on filtering (this default-denies until policies exist).
  3. Add a SELECT policy with USING (auth.uid() = user_id) so users read only their own rows.
  4. Add INSERT, UPDATE, and DELETE policies with the matching auth.uid() = user_id check.
  5. Re-run the pg_policies query to confirm each policy shows the auth.uid() condition, not true.

Related: Move secrets out of the client into edge functions

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.

Never accept USING (true) on a table that holds user data. If a prompt or a Fix click reintroduces it to make data show up, revert and scope the policy to auth.uid() instead. A policy that returns rows is not the same as a policy that is secure.

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.

  1. Create two test accounts (User A and User B) in the deployed build, each with at least one record.
  2. Sign in as User A and confirm the list shows only User A's rows, never User B's.
  3. 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.
  4. Repeat the cross-account read for UPDATE and DELETE intent, not just SELECT, to confirm writes are scoped too.
  5. 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?
Row-Level Security is missing or misconfigured on that table. Lovable apps query Supabase directly from the browser using the public anon key, so an RLS policy is the only access control. If RLS is disabled, or the policy uses USING (true) instead of filtering on auth.uid(), every authenticated request returns the whole table — including other accounts' rows.
How do I check if Row-Level Security is enabled in Supabase?
Open the Supabase SQL Editor and run: SELECT relname, relrowsecurity FROM pg_class WHERE relkind = 'r' AND relnamespace = 'public'::regnamespace ORDER BY relname. Any user-data table where relrowsecurity is false has no row filtering and is exposed. For tables that show true, also check pg_policies, because RLS can be on while the policy still allows everyone to read everything.
Is enabling RLS enough to stop the data leak?
Not by itself. Enabling RLS with no policy default-denies, so your app may return zero rows. Enabling it with a permissive policy like USING (true) is exactly as exposed as having it off. You must add policies scoped to auth.uid() = user_id for SELECT, INSERT, UPDATE, and DELETE, then verify them with two separate accounts before trusting the fix.
What does USING (auth.uid() = user_id) actually do?
It tells Postgres to only return or modify rows where the row's user_id column matches the ID of the currently logged-in user, which Supabase exposes as auth.uid(). The check runs inside the database on every query, so the browser cannot bypass it. It is the standard pattern that scopes each row to its owner and stops cross-account access.
My app worked fine in the Lovable preview — why is it leaking in production?
The editor preview runs as a single user, so a missing or wide-open policy never surfaces; you only ever see your own data because there is only one account. The hole becomes visible the moment a second real user exists. This is why RLS gaps in vibe-coded apps almost always reach production undetected.
Is the Supabase anon key safe to ship in my client code?
Yes, the anon key is designed to be public and lives in your client bundle by design — but only if RLS is correctly configured. The anon key grants no special access on its own; row-level access is decided entirely by your policies. Without correct RLS, that public key effectively makes every row in the table public too. The service-role key, by contrast, must never reach the browser.
Can users edit or delete each other's data, not just view it?
Yes, if the UPDATE and DELETE policies are missing or permissive. A common mistake is scoping the SELECT policy correctly so the UI hides other rows, while leaving writes open. An attacker hitting the API directly could then modify or delete rows they do not own. Always set auth.uid()-scoped policies for all four commands, not just SELECT.
Will adding RLS break my existing Lovable app?
It can if rows have no owner. Enabling RLS default-denies until policies exist, and a policy on user_id only matches rows that already have a user_id set. If existing rows have a null owner they become invisible. Add and backfill a user_id column before enabling RLS, and test on a staging copy or after a backup so live data is never stranded.
Lovable keeps regenerating a policy that exposes all rows. How do I stop it?
When you enable RLS and data disappears, the AI often rewrites the policy to USING (true) to make it reappear, which removes the protection — a false-fixed hallucination applied to security. Revert that change, write the auth.uid()-scoped policy directly in the Supabase SQL Editor, and verify with pg_policies. Avoid re-prompting the same table once a correct policy is in place.
How do I know if other tables have the same problem?
Run the pg_class query to list RLS status across every table at once, then the pg_policies query to read each policy condition. If you find one exposed table, others built in the same session usually share the gap. When real user data was exposed in production, a full security audit checks every table, storage bucket, edge function, and key so nothing is missed. Book a call and we triage the same day.

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.

Get emergency help