Skip to content
Vibe Coders

RLS Policies Lovable Generated Wrong (And How We Rewrote Them)

Three real Supabase RLS failures we found on Lovable projects, with the corrected SQL and why each one shipped broken in the first place.

By WitsCode11 min read

Row Level Security is the part of Supabase that Lovable, Bolt, v0, and every other AI builder quietly gets wrong. Consistently. When WitsCode takes on a Lovable-built app for audit, we now open the Supabase dashboard before the repo. The Advisor tab tells us within thirty seconds whether the anon key in the client bundle is exposing every customer record to anyone who opens the network tab.

This post walks through three real RLS failures we pulled out of Lovable-generated Supabase projects. Names changed, SQL real. For each one we show the broken policy, explain why the AI generated it, and give the corrected version. Along the way we cover two things the top Google results for "supabase rls" rarely put together: the trap of enabling RLS without policies, and the USING versus WITH CHECK distinction that determines whether your writes get validated at all.

Why the same three RLS mistakes keep showing up in Lovable projects

Lovable is good at shipping a working-looking app. It is less good at security boundaries, because security boundaries are invisible when the app runs correctly, and AI pair programmers optimize for the visible thing. When the app throws a 401 because RLS is blocking a query, the AI sees a bug and fixes the bug. The fix is almost always one of three things: disable RLS, write USING (true), or swap the anon key for the service role key. Each of those makes the 401 go away. Each of those is a different severity of data leak.

The founder sees the preview working and clicks deploy. The vulnerability ships. Nothing about the product tells them anything is wrong. The Supabase dashboard prints advisor warnings in the corner of a page they never visit, and by the time someone notices, the app has real users and rewriting the policies means figuring out which writes from the last two months actually belonged to the user who made them.

The three cases below are the shape of damage we keep finding.

Case one: the support ticket table with no policies at all

The first project was a B2B help desk. Customers log in, file tickets, and see their history. Lovable had generated a support_tickets table with this shape:

create table public.support_tickets (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references auth.users(id),
  subject text not null,
  body text,
  status text default 'open',
  created_at timestamptz default now()
);

RLS was not enabled. The advisor tab in Supabase had been flashing red for six weeks. The founder had asked Lovable why, and Lovable had responded by suggesting they enable RLS. Lovable then enabled RLS. The app broke. Lovable turned it back off. The founder accepted that and moved on.

The consequence is the consequence of every unprotected table in a Supabase project: the anon key, which ships in every client bundle by design, can issue REST queries against PostgREST, and PostgREST happily returns every row. A competitor, a bored user, or a bot scraping Lovable-built projects can pull the entire support history of every customer with one curl command.

The fix has two parts. First, turn RLS on for real.

alter table public.support_tickets enable row level security;

Second, and this is where Lovable failed the last time it tried, write actual policies. Enabling RLS without policies gives you a table that blocks everyone except the service role, which is why the app broke when Lovable tried it. You need one policy per operation you want to allow, and each policy needs to be scoped to the correct role and the correct predicate.

create policy "tickets_select_own"
  on public.support_tickets
  for select
  to authenticated
  using ( (select auth.uid()) = user_id );

create policy "tickets_insert_own"
  on public.support_tickets
  for insert
  to authenticated
  with check ( (select auth.uid()) = user_id );

create policy "tickets_update_own"
  on public.support_tickets
  for update
  to authenticated
  using ( (select auth.uid()) = user_id )
  with check ( (select auth.uid()) = user_id );

create policy "tickets_delete_own"
  on public.support_tickets
  for delete
  to authenticated
  using ( (select auth.uid()) = user_id );

Four policies, one per operation. Each one is scoped to authenticated, which means the anon role gets nothing at all from this table, which is what you want for a table that contains customer support content. The select policy uses auth.uid() wrapped in a subquery, which is a Supabase performance convention that lets Postgres cache the value once per statement instead of re-evaluating it per row. On a table with a hundred thousand rows and an index on user_id, that matters.

The important observation, and the thing that breaks Lovable's mental model, is that having RLS enabled with zero policies is not safe. It is not permissive. It is inert from the anon and authenticated roles, but it is still fully readable from the service role. If the app is using the service role key because the anon key got blocked, you have shipped your entire database to the browser. The correct state is RLS enabled plus an explicit policy for every operation that should be possible.

Case two: the invoices table that let every user read every invoice

The second project was a billing dashboard for a small SaaS. The invoices table had customer names, amounts, line items, and tax IDs. When we opened the project, there was one policy on the table, and it read like this:

create policy "Enable read access for all users"
  on public.invoices
  for select
  using (true);

The policy name is a Supabase dashboard template. When you click "New Policy" in the UI, one of the starter templates is literally called "Enable read access for all users" and it comes prefilled with using (true). Lovable reached for that template because the app kept throwing 401s when users loaded the billing page, and this made them stop.

using (true) means every row passes the filter. For a select policy scoped to no specific role, which is what this one was, every user, including the anon role, could read every invoice in the system. The billing dashboard itself worked fine because the user only queried their own invoices. But anyone who sent a different REST request, filtering by a different user_id, got that user's invoices back. We tested it. It worked from an incognito window with nothing but the anon key.

The rewrite is not complicated. Drop the bad policy, write one that actually checks ownership, and scope it to the authenticated role.

drop policy "Enable read access for all users" on public.invoices;

create policy "invoices_select_own"
  on public.invoices
  for select
  to authenticated
  using ( (select auth.uid()) = customer_id );

The more interesting question is why Lovable picked that template. The Supabase dashboard offers starter templates in the policy editor, and one of them is literally "Enable read access for all users." It exists for public-facing tables like country lists or product catalogs. But the template does not warn "do not use this on tables containing personal data." An AI trying to get a 401 to go away picks the template that stops the 401. The name reads as a green light.

The lesson is that policy templates are not safe defaults. They are starting points for a human who understands the tenant model. Lovable does not understand your tenant model, because you have not told Lovable what your tenant model is, and even if you had, Lovable's job is to make the preview work, not to prove that writes from user A cannot be read by user B.

Case three: the multi-tenant document table with a join subquery that leaked everything

The third project was an agency collaboration tool. Workspaces contain documents. Users belong to workspaces through a workspace_members table with a role column. Lovable had written what looked like a thoughtful policy:

create policy "documents_workspace_access"
  on public.documents
  for select
  to authenticated
  using (
    workspace_id in (
      select workspace_id from public.workspace_members
    )
  );

Read the subquery. It selects every workspace_id from the members table. Not "every workspace_id where user_id equals the current user." Every workspace_id in the entire table. So the policy resolves to "you can read a document if its workspace_id appears anywhere in the membership table," which is true for every document in every workspace that has at least one member. Which is every document. Every user in the system could read every document in every workspace.

This is the failure mode we see most often on multi-tenant Lovable apps, because the AI understands that a join is involved, produces something join-shaped, and never stops to ask whether the join is actually filtering on the current user. The subquery compiles, the types line up, the app stops throwing 401s, and the feature ships.

The correct version uses an EXISTS subquery with two conditions: the workspace match and the user match. We prefer EXISTS over IN here because it reads more clearly, and because the planner can short-circuit on the first matching row.

drop policy "documents_workspace_access" on public.documents;

create policy "documents_workspace_read"
  on public.documents
  for select
  to authenticated
  using (
    exists (
      select 1
      from public.workspace_members wm
      where wm.workspace_id = documents.workspace_id
        and wm.user_id = (select auth.uid())
    )
  );

You also want a supporting index, because without one, every row read has to scan the members table.

create index if not exists workspace_members_user_workspace_idx
  on public.workspace_members (user_id, workspace_id);

If you have role-based permissions inside the workspace, this is where you add them. Read access for all members, update access only for editors and owners, delete access only for owners. Each one is its own policy with the same EXISTS pattern plus a role check.

create policy "documents_workspace_update"
  on public.documents
  for update
  to authenticated
  using (
    exists (
      select 1
      from public.workspace_members wm
      where wm.workspace_id = documents.workspace_id
        and wm.user_id = (select auth.uid())
        and wm.role in ('editor', 'owner')
    )
  )
  with check (
    exists (
      select 1
      from public.workspace_members wm
      where wm.workspace_id = documents.workspace_id
        and wm.user_id = (select auth.uid())
        and wm.role in ('editor', 'owner')
    )
  );

Yes, the predicate is duplicated between USING and WITH CHECK. That is deliberate, and the next section explains why.

The USING versus WITH CHECK distinction that almost never survives AI generation

Every AI-generated RLS policy we have reviewed gets this distinction wrong in at least one direction. The Supabase docs cover it, but the explanation is buried under examples, and Lovable reads the examples, not the explanation.

USING is the predicate Postgres evaluates against rows that already exist. On SELECT, rows that fail USING are filtered out of the result. On UPDATE and DELETE, rows that fail USING are invisible to the operation and therefore cannot be updated or deleted. USING is a read-side filter.

WITH CHECK is the predicate Postgres evaluates against rows being written. On INSERT, the new row must satisfy WITH CHECK or the insert is rejected. On UPDATE, the row after the update must satisfy WITH CHECK, which prevents a user from updating a row to belong to someone else. WITH CHECK is a write-side gate.

The failure modes are specific. If you write a FOR UPDATE policy with only USING and no WITH CHECK, a user can update their own row and change the user_id to somebody else's, and the update will succeed, because USING was satisfied by the row before the change and WITH CHECK was not there to validate after. If you write a FOR INSERT policy with only USING, nothing happens, because INSERT has no existing rows for USING to evaluate against, and the policy effectively does not exist. We have seen both mistakes in the same project.

The rule we use on WitsCode audits: every FOR UPDATE policy needs both USING and WITH CHECK, and they should almost always be the same predicate. Every FOR INSERT policy needs WITH CHECK only. Every FOR DELETE policy needs USING only. Every FOR SELECT policy needs USING only. FOR ALL policies need both, because FOR ALL covers all four operations and each of them has different requirements. If you see a policy missing the clause it should have, the policy is probably wrong, even if the app appears to work.

How to audit a Lovable Supabase project in half an hour

The audit we run on incoming projects is short enough to describe. Open the Supabase dashboard. Go to the Advisor tab under Database. Every table listed there as missing RLS is a problem. Every function listed as having a mutable search path is a problem. Write them down.

Open the SQL editor and run this query to list every policy in the public schema:

select
  schemaname,
  tablename,
  policyname,
  cmd,
  roles,
  qual as using_expression,
  with_check as with_check_expression
from pg_policies
where schemaname = 'public'
order by tablename, cmd;

Read every row. For each policy, ask three questions. Is the predicate actually restricting to the current user or the current tenant, or does it resolve to true for everyone? Is it scoped to a specific role like authenticated, or does it apply to public and therefore to the anon role? For update policies, are both using_expression and with_check_expression populated, or is one of them null?

Tables with RLS enabled and zero policies are the next thing to find.

select c.relname as table_name, c.relrowsecurity as rls_enabled
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public'
  and c.relkind = 'r'
  and c.relrowsecurity = true
  and not exists (
    select 1 from pg_policies p
    where p.schemaname = 'public' and p.tablename = c.relname
  );

Any table that comes back from that query is either legitimately server-only, or it is a table your app is trying to reach with the service role key because the anon and authenticated roles got blocked. The second case is the one that ships your database to the browser.

Finally, grep your client code for SUPABASE_SERVICE_ROLE_KEY or service_role. That string should appear only in server-side code, which on a Lovable project means edge functions. If it appears anywhere in the React tree, in a Vite env file prefixed with VITE_, or in a Next.js env var prefixed with NEXT_PUBLIC_, stop reading and rotate the key immediately.

When to hand this off

If the audit above found anything, and on Lovable-built projects it almost always does, the fix is not one SQL file. It is a migration that enables RLS on the right tables, drops the bad policies, writes the correct policies with the right USING and WITH CHECK clauses for your tenant model, adds indexes to support the membership lookups, and verifies the result against a test matrix that covers anon reads, authenticated reads of your own rows, authenticated reads of someone else's rows, and authenticated writes that try to change ownership.

That is a few hours of work if you know what you are doing and a week of reading the Supabase docs if you do not. WitsCode runs a fixed-scope RLS audit and rewrite for Lovable projects. We open your Supabase project, run the queries above, ship a migration file that enables RLS correctly on every table that needs it, rewrites the bad policies with proper ownership and tenant checks, and includes a short written explanation of what was wrong so you understand the change rather than just apply it. We also leave you with the test queries so you can verify future policies yourself.

If your Lovable app has users, and the Supabase advisor has warnings you have been ignoring, the gap between your current state and the state where an audit would find nothing is smaller than it feels. The first step is looking.

-> Book a WitsCode RLS audit and rewrite for your Lovable Supabase project.

Get weekly field notes.

Practical writing on shipping products, straight to your inbox. No spam.

Need help with this?

MVP Development

We design and build web apps, MVPs, and SaaS products. Talk to us about what you are working on.

Talk to us

Want to discuss vibe coders for your business?

Start a project and we'll talk through where you are, what's working, and the highest-leverage moves for the next 90 days.