Your app is live. The URL works, the database has real rows in it, and somebody has just opened the browser developer tools, found the Supabase project URL and the public "anon" key that your frontend is built to expose, and sent one request to your database. If that request comes back with your customers' names, emails and addresses, your tables have no Row Level Security. The key was never the problem.
This is not a rare failure. In the 2026 vibe-eval benchmark, 59% of the apps sampled had at least one RLS gap, and 10.3% of sampled Lovable apps exposed real user data through missing Row Level Security. symbioticsec.ai reported that 98% of apps behind a public Supabase URL had at least one exploitable flaw.
The design explains why this happens. The project URL and the anon key are public on purpose. They ship inside your JavaScript bundle, and Supabase grants the anon role access to your tables by default. Your only defence is a rule inside Postgres that says which signed-in user may read which row. If that rule was never written, the public key reads everything.
What "RLS not enabled" looks like in your code
The scanner reads your SQL migrations. This is the pattern it finds:
create table public.profiles (
id uuid primary key references auth.users (id) on delete cascade,
full_name text,
email text,
phone text,
role text default 'user',
company text,
created_at timestamptz default now()
);
A table holding names, emails and phone numbers, and no enable row level security anywhere in the file. Postgres does not filter rows for that table at all, so the anon role gets whatever the default grants give it.
Three more shapes count as the same problem.
RLS is on, but the policy underneath allows everything.
alter table public.orders enable row level security;
create policy "anyone can read orders" on public.orders
for all
using (true)
with check (true);
The table looks protected in the dashboard. The rule allows every row for every role, so the effect is identical to having no rule.
Access granted straight to the public role.
grant all on table public.profiles to anon;
That hands the unauthenticated role full access to a table with no RLS on it.
A function that runs with admin rights and no pinned search path.
create or replace function public.get_company_stats(company_id uuid)
returns jsonb
language plpgsql
security definer
as $$
declare
result jsonb;
begin
select jsonb_build_object(
'employees', (select count(*) from public.profiles where role = 'employee'),
'plan', (select plan from public.companies where id = company_id)
) into result;
return result;
end;
$$;
security definer means the function runs with the privileges of its owner, usually the database owner. Without set search_path, a caller can hijack the path and make the function resolve objects they control. This is the classic Postgres privilege-escalation path.
The fix
Turn the rule on, then write it per table. A table that belongs to a person:
alter table public.profiles enable row level security;
create policy "users read own row" on public.profiles
for select to authenticated
using (auth.uid() = id);
create policy "users update own row" on public.profiles
for update to authenticated
using (auth.uid() = id)
with check (auth.uid() = id);
A table that belongs to a company:
create policy "owners read own company" on public.companies
for select to authenticated
using (owner_id = auth.uid());
Pin the search path on every elevated function:
create or replace function public.get_company_stats(company_id uuid)
returns jsonb
language plpgsql
security definer
set search_path = public, pg_temp
as $$
...
$$;
One trap sits on the other side. If you enable RLS and write no policy at all, the table locks itself. Nobody can read it, including your signed-in users, and it usually shows up as "the app stopped working" after a fix. Write a policy for every table you enable, and add insert and delete if the app needs them.
The table that is not in your code
One more case is worth knowing about. Tables created straight in the Supabase dashboard leave no migration in your repository, so nothing in the code records whether row-level security was turned on for them. The scanner reports those as tables your app uses that it could not review, rather than claiming they are safe. The fix is to export the schema from the Supabase SQL editor and commit it to the repository, so the access rules are visible and reviewable. Dashboards are exactly where row-level security gets forgotten, because there is no pull request where somebody would notice.
Checking it in one request
Once the policies are in, the same anonymous request that used to return your customers should come back empty, or 401, or 403. That is the live check in the scanner. It sends exactly one read request for one table, and it never signs in. It also reports what it could not check, so a table it never saw is never counted as a pass.
What to do next
A scanner will name the table. It will not write the policy that fits your schema, and it will not tell you which of the four operations your app actually needs. The $199 review does that. It reads your migrations, lists every table that needs a policy, and hands you the SQL for each one so you can paste it straight into your builder. 48 hours, fixed price. It is a review of your code, not a penetration test of your live site, and it is not a guarantee that your app is secure.
The scanner that finds this is open source: github.com/rafikulislammollaq-dev/vibeaudit
It also runs in the browser, where your project is read on your own machine and never uploaded: rafikulislammollaq-dev.github.io/vibeaudit/scanner
If you would rather have a person read the findings and explain them, that is a $199 review: rafikulislammollaq-dev.github.io/vibeaudit
Top comments (0)