DEV Community

Artiom Psenicinii
Artiom Psenicinii

Posted on

Eight ways a Lovable + Supabase app leaks its users' data — and how to check yours in ten minutes

If you built your app with Lovable, Bolt or a similar tool on top of Supabase, it probably works. Users sign up, data saves, the dashboard looks right. That is not the same as the data being safe, and the gap between the two is where most of these apps sit right now.

This is not hypothetical. In March 2025 Matt Palmer reported that Lovable-generated applications were shipping without working Row Level Security. It became CVE-2025-48757: 170+ affected applications, 303 vulnerable endpoints, exposing emails, phone numbers, payment and subscription data, and third-party API keys — some with write access, meaning payment records could be modified and not merely read.

Lovable's answer, shipped in 2.0, was a built-in security scanner. That scanner checks whether RLS is enabled on your tables. It does not check whether your policies do anything. Every pattern below leaves RLS enabled and shows green.

None of this is a criticism of the tools. They generate working code quickly, and RLS is the one part of Supabase that cannot be inferred from "build me an app where each company sees its own projects". It has to be written deliberately, table by table, and the generator has no way of knowing where your tenant boundary really is.

Below are the eight failure modes I see most often, ordered roughly by how badly they end. Every one can be checked from the Supabase SQL editor in a couple of minutes. Run them on your own project.

  1. The user's role lives in user metadata

This is the worst one, and it is common because it is the path of least resistance.

Supabase gives every user a user_metadata object. It is convenient: you store role: "admin" or org_id there, and your policies read it out of the JWT. The problem is that user_metadata is writable by the user. Any logged-in user can open the browser console and run:

js
await supabase.auth.updateUser({ data: { role: 'admin' } })

and they are now whatever they said they were. If your policies trust that field, you do not have access control, you have a suggestion.

Check:

sql
select tablename, policyname, qual, with_check
from pg_policies
where qual ilike '%user_meta%' or with_check ilike '%user_meta%'
or qual ilike '%raw_user_meta%' or with_check ilike '%raw_user_meta%';

Any row returned is a critical finding.

Fix: roles and organisation membership belong in their own table, keyed by auth.uid(), with an RLS policy that lets users read their own membership and nobody write it from the client. app_metadata is the other option — it is not user-writable — but a table is easier to reason about and to audit later.

  1. RLS is simply off on some tables

Every table in the public schema is exposed through the REST API. If RLS is not enabled on it, the anon key — the one sitting in your frontend bundle, visible to anyone who opens devtools — reads the whole table.

Check:

sql
select c.relname, c.relrowsecurity
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public' and c.relkind = 'r'
order by c.relrowsecurity, c.relname;

Everything with relrowsecurity = false is public. Supabase's dashboard warns about this, but the warning is easy to dismiss when you are shipping.

  1. The policy exists and does not constrain anything

This is the one the scanners cannot see, because structurally these are real policies.

The blunt version appears when someone hits a permission error during development and asks an AI to fix it:

sql
create policy "enable read for authenticated"
on public.orders for select
to authenticated
using (true);

That fixes the error perfectly. Every logged-in user now reads every order in the system. Worth noting that with anonymous sign-ins enabled — one toggle in the Auth settings — authenticated stops being a meaningful boundary at all, and to authenticated becomes decoration.

The subtle version is more interesting, and I find it more often:

sql
create policy "members can read"
on public.orders for select
to authenticated
using (
exists (
select 1 from public.memberships m
where m.user_id = auth.uid()
)
);

Read it out loud: allow if there exists a membership belonging to me. That is the entire condition. It never mentions orders. It never correlates the membership to the row being filtered. So for every row in the table Postgres asks the same question — does this user belong to any organisation at all — gets true, and returns the row.

Sign up, create your own free workspace, and you have read access to every order of every customer. No exploit chain. Just select * from orders.

The fix is one join back to the row:

sql
using (
exists (
select 1 from public.memberships m
where m.user_id = (select auth.uid())
and m.tenant_id = orders.tenant_id -- the line that was missing
)
)

The (select auth.uid()) wrapper is not cosmetic either: it lets the planner evaluate the function once per query instead of once per row. On a table of any size that is the difference between a policy that stays and a policy that gets ripped out for being slow.

Check — find the blunt ones automatically, then read the rest by hand:

sql
select tablename, policyname, cmd, roles, qual, with_check
from pg_policies
where schemaname = 'public'
and (qual in ('true') or with_check in ('true') or qual is null)
order by tablename;

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

For the second query there is no shortcut. You are looking for conditions that do not mention the protected table.

  1. RLS is on, no policy exists, and someone reached for the service role

Enabling RLS without writing a policy denies everything. The table returns zero rows, the feature breaks, and the fastest way to make it work again is to call Supabase with the service role key instead of the anon key.

The service role bypasses RLS entirely. If that key is anywhere the browser can reach it, every protection in your database is off for anyone who finds it.

Check: search your repository for service_role, SUPABASE_SERVICE_ROLE_KEY and eyJ. The VITE_ / NEXT_PUBLIC_ prefix rules do protect you for environment variables — Vite strips unprefixed vars, Next.js replaces them with undefined in client code. What the prefix does not protect you from is a key written directly into source, or a module holding the key being imported by a client component. Build the frontend and grep the built bundle:

bash
npm run build && grep -r "service_role" dist/ build/ .next/ 2>/dev/null

Any JWT you find, paste into a decoder — the payload is base64, not encrypted — and look at the role claim.

Then check history:

bash
git log -p -S 'service_role' | head -50

A key that was ever committed is compromised even if it was removed later. Rotate it. There is no "probably nobody noticed" — public repositories get scraped continuously.

  1. Policies with no WITH CHECK

A SELECT policy controls what a user can read. INSERT and UPDATE need WITH CHECK to control what they can write. USING decides which rows you may target; WITH CHECK decides what the row is allowed to look like afterwards.

Without it, a user can update a row and set org_id to someone else's organisation — moving their own record into your other customer's account, or pulling a record out of it.

Check:

sql
select tablename, policyname, cmd, qual, with_check
from pg_policies
where schemaname = 'public'
and cmd in ('INSERT','UPDATE','ALL')
and with_check is null;

FOR ALL policies are the usual suspects — they look complete and are missing half the protection.

  1. Views and definer functions quietly bypass RLS

A view runs with the privileges of its owner, and in Supabase that owner is usually postgres. So a view over a protected table returns everything, regardless of the policies on the table underneath. Postgres 15 added security_invoker to fix this, and it is off by default.

Check:

sql
select c.relname, c.reloptions
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public' and c.relkind = 'v';

If reloptions does not contain security_invoker=true, the view is a hole around your policies.

sql
alter view public.my_view set (security_invoker = true);

Materialized views do not support security_invoker at all. If one holds tenant data, it does not belong in an API-exposed schema.

The same applies to SECURITY DEFINER functions, which are exposed as /rest/v1/rpc/. They ignore RLS by design. Everyone building multi-tenant on Supabase ends up writing at least one, because a policy on memberships that queries memberships throws infinite recursion detected in policy and a definer function is the standard escape.

That is fine — until the function takes a tenant id as an argument and never checks that the caller belongs to it:

sql
create function public.get_tenant_orders(p_tenant_id uuid)
returns setof public.orders
language sql
security definer
as $$
select * from public.orders where tenant_id = p_tenant_id;
$$;

Callable over REST by any authenticated user, runs outside RLS by definition. Your policies are irrelevant; there is a documented endpoint that ignores them.

Two things to verify on every definer function — that authorisation is checked inside the body, and that search_path is pinned, since an unpinned one lets anyone who can create objects in an earlier schema hijack what the function calls:

sql
select n.nspname, p.proname, p.proconfig
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public'
and p.prosecdef
and (p.proconfig is null
or not exists (select 1 from unnest(p.proconfig) c where c like 'search_path=%'));

Fix with set search_path = '' and fully-qualified names in the body.

  1. Storage buckets that are public, and signed URLs that never expire

Files are the part everyone forgets. A bucket marked public serves every object in it to anyone with the URL — and object paths are guessable far more often than people think.

Check:

sql
select id, name, public from storage.buckets;

Then look at your createSignedUrl(path, expiresIn) calls. An hour is reasonable. A year is a permanent public link with extra steps. An individual signed URL cannot be revoked before it expires; the only lever is rotating the project's JWT secret, which invalidates every signed URL you have ever issued at once. Plan the expiry as if you will never get to take it back.

The pattern that works: private bucket, object paths beginning with the organisation id, and a policy on storage.objects comparing that first path segment against the caller's membership — (storage.foldername(name))[1].

  1. Edge Functions that trust their caller

Edge Functions are where the service role legitimately lives. That makes them the most dangerous thing in the project when they do not check who is calling.

Two questions for every function. Does it verify the caller's JWT — verify_jwt in config.toml, not set to false? And having identified the caller, does it check that this specific user is allowed to touch this specific organisation's data, rather than doing what the request body says?

A function that takes { org_id, action } from the request and executes it with the service role is a public admin API. It does not matter that your UI only ever sends the right org_id.

The ten-minute version

If you only do one thing, do this. Open the SQL editor and run the queries from sections 1, 2, 3 and 7 — user metadata in policies, tables without RLS, policies that do not constrain, public buckets. Four queries, four minutes. They catch the majority of what actually goes wrong.

Then do the one test that no query can replace. Create two accounts in two different organisations, take the access token of the first, and try to read the second one's data through the REST API:

bash
curl "https://.supabase.co/rest/v1/

?select=*" \
-H "apikey: " \
-H "Authorization: Bearer "

Count the rows. Check who they belong to. Reading your policies tells you what you meant; this tells you what you built.

Do the write side too — try to insert a row carrying another organisation's id, and try to update one of your rows into theirs. A read-only test misses section 5 entirely.

One thing not to do

Do not run any of this against somebody else's application. Reading a public JavaScript bundle is one thing; querying a database you were not given access to is unauthorised access in most jurisdictions, and "I was going to tell them" is not a defence. Test what is yours, or what you have written permission to test.

I audit multi-tenant Supabase setups — RLS, storage, Edge Functions and key exposure — and deliver a written report with reproduction steps and an estimate of the work to fix each finding. Written and asynchronous, no calls.

The full checklist I work from is open on GitHub: github.com/Obi1Kanoobie/supabase-multitenant-security-checklist

If you want a second pair of eyes on your project before your first real customer logs in: tele@duck.com

Top comments (0)