Guide · October 2026

Lovable's security scan passed. Here are 5 ways your Supabase data can still be exposed

A green security scan means the obvious mistakes are gone. It doesn't mean your access rules do what you think.

Lovable builds its security scan on Supabase's Security Advisor, the database linter in your Supabase dashboard. The linter is good at what it checks: tables with row level security (RLS) turned off, update and delete policies that are literally true, views that skip RLS, policies that trust user_metadata. Keep using it.

But a linter checks patterns, and the five mistakes below don't match its patterns. What the linter does and doesn't check comes from reading its source as of October 2026. Mistakes 1 and 4 were reproduced on a test Postgres database set up like Supabase.

1. "Signed out" counts as "allowed"

using (auth.uid() is null or user_id = auth.uid())

For a signed-out request auth.uid() is null, so this returns every row to anyone holding your public anon key. The linter doesn't flag it because the policy contains a real owner check. A proposal to detect it is still open. Full write-up and fix →

2. "Anyone can read" on a table with personal data

create policy "profiles are viewable by everyone" on profiles
  for select using (true);

This is the default shape for a profiles table, and it's fine if the table only holds a display name and an avatar. Often it also holds email, phone or an address, and then anyone can download all of them.

The linter skips this on purpose. Its "always true" check (lint 0024) covers insert, update and delete, and its description says "SELECT policies with USING (true) are intentionally excluded as this pattern is often used deliberately for public read access." Its sensitive-columns check (lint 0023) only looks at tables where RLS is off.

Fix: keep public fields and private fields in different tables. Put display_name and avatar_url in a table everyone can read, and keep email and phone in a table only the owner can read:

create policy "owner reads own private profile" on private_profiles
  for select to authenticated
  using ((select auth.uid()) = id);

3. "Signed in" counts as "allowed"

using (auth.uid() is not null)
using (auth.role() = 'authenticated')

These look stricter than true, and the linter treats them that way: they aren't the literal true it searches for. But sign-up in Supabase Auth is open by default, so "any signed-in user" means "anyone who creates an account", which takes ten seconds. On a select policy, every user can read every other user's orders or messages. On an update or delete policy, every user can change or delete them.

Fix: check ownership, not just sign-in: using ((select auth.uid()) = user_id).

4. Users can make themselves admin

create policy "users update own profile" on profiles
  for update to authenticated
  using (id = auth.uid());

This only lets users edit their own row. But if that row has a role, is_admin, plan or credits column, they can edit that too, from the browser console:

await supabase.from('profiles')
  .update({ role: 'admin' })
  .eq('id', (await supabase.auth.getUser()).data.user.id)

On our test database this succeeded. It gets worse when other tables decide who is an admin by reading that column, like exists (select 1 from profiles where id = auth.uid() and role = 'admin'). The linter has no check for this.

Fix: let users update only the columns they're meant to edit. Note that revoking a single column doesn't work while the table-wide grant exists, so replace the grant:

revoke update on public.profiles from authenticated;
grant update (full_name, avatar_url) on public.profiles to authenticated;

After this, changing role fails with "permission denied" while editing the name still works. Changes to role should come from your server (service role) or an admin-only function.

5. A private bucket anyone can list

create policy "read documents" on storage.objects
  for select using (bucket_id = 'documents');

Marking a bucket private stops downloads by public URL. It doesn't stop the API: with this policy, anyone with your anon key can call supabase.storage.from('documents').list() and download every file in it.

The linter has a check for broad listing policies (lint 0025), but it only runs on public buckets. The private bucket, which is where the invoices, IDs and contracts usually live, isn't covered.

Fix: store each user's files in a folder named after their user ID and check it:

create policy "users read own documents" on storage.objects
  for select to authenticated
  using (bucket_id = 'documents'
         and (storage.foldername(name))[1] = (select auth.uid())::text);

Check all five in two minutes

RLS Check reads your policies, grants and bucket settings with one read-only query you run yourself, then explains in plain English who can reach what. It covers these five plus the basics, marks which findings the Supabase Advisor already reports, and gives you a prompt to paste into Lovable or Cursor for each fix. It's free, needs no sign-up, and the result never leaves your browser.

Check your Supabase project →