Supabase RLS Mistakes in AI-Generated Apps (and How to Fix Them)
Supabase RLS not working in your Lovable or Bolt app? Seven mistakes AI tools make, with SQL fixes and a test you can run today. Book a scope call.
Supabase RLS is the control that decides which rows a client may see once the request hits your database. AI builders wire Supabase into a Lovable or Bolt app in minutes. Auth works. The table fills. The screen looks finished. The leak is usually here: a table with no row filter, a policy that says true, or a key that bypasses every policy from the browser. Founders notice the product. Attackers notice the Data API.
The Register reported on researcher Taimur Khan's findings in one Lovable-hosted app: 16 vulnerabilities, six of them critical, and more than 18,000 people's data exposed. Khan also pointed to a wider scan of 1,645 Lovable apps where 170 had critical flaws, as covered in TechRadar's reporting on the Lovable security fallout. That pattern matches the 91% audit of deployed vibe-coded apps: access control is the failure that shows up most often. This post is the Supabase version of that failure — seven mistakes, SQL fixes, and a test you can run today before the next feature ships.
What Supabase RLS does, in plain English
Your Lovable or Bolt frontend talks to Supabase with a publishable key. That key is public by design. Anyone who opens DevTools can copy it. Supabase expects that. The safety is not the key. The safety is row level security: Postgres policies that say, for this role and this operation, which rows are allowed.
Without RLS on a table in an exposed schema, any client that holds a matching grant can read or write every row through the Data API. Supabase's docs are blunt about it: until you enable RLS and write a policy, anyone with the publishable key can read and write every row. The Table Editor turns RLS on for you when you create a table in the Dashboard. When you create a table with SQL or a migration, you enable it yourself.
So the one-line check that matters is not "does the UI hide the admin page." It is: can I select every row with the anon key?
-- From a client that only has the anon (publishable) key and no user session:
-- select * from your_table;
-- If this returns other users' rows, RLS is not doing its job.
RLS and grants are two layers. Grants decide whether anon or authenticated may run select or insert at all. Policies decide which rows survive that operation. Fix both. A pretty policy on a table that still grants everything to anon is not enough if you never enabled RLS. An enabled table with no policy returns no rows through the Data API for normal clients, which is why agents "fix" broken apps by opening the door wide.
Think of the publishable key as a doorbell, not a lock. Anyone can ring it. Supabase RLS is the lock on each room. If you never installed the lock, or you installed one that opens for every visitor, the doorbell was never the problem. That is also why hiding an admin link in the UI changes nothing: the REST path still accepts the same key your page uses.
The seven Supabase RLS mistakes AI tools make
These are the patterns we see in AI-generated Supabase apps. Each one has what the model does, why it hurts, and a fix with a concrete policy.
1. RLS never enabled on a table created through SQL
What the AI does. It writes a migration or a SQL editor script: create table public.orders (...). It stops there. No enable row level security. The Dashboard would have turned RLS on. SQL does not.
Why it's dangerous. Per Supabase, tables created through the Dashboard have RLS enabled by default; tables created in the SQL Editor or another tool need an explicit enable. On projects that still grant anon and authenticated broadly, that table is readable and writable through the Data API with the publishable key. Your AI-generated app exposed Supabase data without anyone logging in. The UI can still "feel" private because it only queries the current user's id — the API will happily answer a wider query.
The fix. Enable RLS in the same migration that creates the table, then add policies and tighten grants. Treat every SQL-created table as open until the migration proves otherwise.
alter table public.profiles enable row level security;
create policy "Users read own profile"
on public.profiles for select to authenticated
using ((select auth.uid()) = user_id);
2. RLS enabled with no policies, then "fixed" with using (true)
What the AI does. Someone enables RLS. The app suddenly returns empty lists. The next prompt is "make the data show up again." The model adds a policy with using (true) for everyone, or recreates the open table under a new name.
Why it's dangerous. Empty results mean the gate is closed. using (true) means the gate is gone. That is the classic path from "supabase rls not working" in the chat to a fully open table in production. Lovable supabase rls security is not "any policy exists." It is "the policy matches who may see the row." A scanner that only checks for the presence of a policy will miss this.
The fix. Prefer a deny-by-default table and a narrow allow. Start from the starter shape and expand per table. If the app is empty after enable, fix the policy expression — do not delete the lock.
alter table public.profiles enable row level security;
create policy "Users read own profile"
on public.profiles for select to authenticated
using ((select auth.uid()) = user_id);
create policy "Users update own profile"
on public.profiles for update to authenticated
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);
3. Policies that trust user_metadata the user can edit
What the AI does. It stores role: admin in raw_user_meta_data / user_metadata and writes policies that check that JSON. The signup or profile form can write the same field.
Why it's dangerous. Users can often change their own user_metadata. app_metadata is meant for claims only your backend or service role sets. A roles table you control is clearer. If the policy trusts client-editable metadata, any user can promote themselves. The demo account never tries that path, so the hole stays quiet until someone curious does.
The fix. Prefer app_metadata set server-side, or a roles / memberships table keyed by auth.uid(). Never let the client decide whether it is an admin.
-- Prefer claims only your backend writes, or a roles table you own.
create policy "Admins read all profiles"
on public.profiles for select to authenticated
using (
(auth.jwt() -> 'app_metadata' ->> 'role') = 'admin'
);
-- Or:
-- using (exists (
-- select 1 from public.roles r
-- where r.user_id = (select auth.uid()) and r.role = 'admin'
-- ));
4. Missing with check on insert and update
What the AI does. It writes a select policy on user_id = auth.uid(), then an insert or update policy with only using (...), or with no check that the written user_id matches the session.
Why it's dangerous. using filters existing rows for update and delete. with check validates the new row. Without it, a logged-in user can insert a row as someone else, or update user_id to another account and keep the data. That is how "I can only see my rows in the UI" still becomes a cross-tenant write. Read policies can look correct while writes are wide open.
The fix. Mirror the ownership check on both sides for write policies. Test inserts and updates with a second account, not only selects.
create policy "Users insert own profile"
on public.profiles for insert to authenticated
with check ((select auth.uid()) = user_id);
create policy "Users update own profile"
on public.profiles for update to authenticated
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);
5. Policies that do not restrict to authenticated
What the AI does. It creates a policy for public / all roles, or omits to authenticated, so the same rule applies to anon.
Why it's dangerous. Anonymous clients then get the same row filter as signed-in users. If the using expression is weak, or always true, signed-out visitors read the table. The Register case included inverted auth logic that let unauthenticated callers through while blocking logged-in users. Wrong role targeting is the cousin of that bug.
The fix. Name the role on every policy. Give anon only what a signed-out visitor must see.
create policy "Users read own profile"
on public.profiles for select to authenticated
using ((select auth.uid()) = user_id);
-- Public marketing content only, if you truly need it:
create policy "Anyone can read published posts"
on public.posts for select to anon, authenticated
using (published = true);
6. The service_role key in frontend code or a browser env var
What the AI does. It puts SUPABASE_SERVICE_ROLE_KEY in a Vite / Next public env, or pastes it into client-side fetch helpers "so RLS does not block the admin screen."
Why it's dangerous. The service role bypasses RLS. In the browser it is a master key for your project. Anyone who views source has it. Rotate it the moment it was ever shipped to a client, then move privileged work to a server you control.
The fix. Keep service_role only on the server. Browser clients use the publishable key and user JWT. Admin paths go through your backend.
-- No SQL fixes a leaked service_role key.
-- Rotate the key in the Supabase dashboard, then keep it server-only.
-- Client code must use the publishable key + the user's session.
7. Views and storage buckets that bypass table policies
What the AI does. It secures public.documents, then exposes a view or a storage bucket with looser rules. Or it creates a security definer function that reads the table as the owner and grants execute broadly.
Why it's dangerous. RLS on the base table does not automatically make every view and every object path safe. Storage has its own policies. A view marked in a way that bypasses caller rights, or a bucket path like user_id/file with a policy of true, reopens the leak beside a correct table policy. Supabase security best practices mean checking every exposed object, not only the first table the agent created.
The fix. Enable RLS (or equivalent controls) on views you expose, lock storage paths to auth.uid(), and grant execute on sensitive functions only to the roles that need them.
-- Example: storage objects only under the caller's folder
create policy "Users read own files"
on storage.objects for select to authenticated
using (
bucket_id = 'uploads'
and (storage.foldername(name))[1] = (select auth.uid())::text
);
create policy "Users upload own files"
on storage.objects for insert to authenticated
with check (
bucket_id = 'uploads'
and (storage.foldername(name))[1] = (select auth.uid())::text
);
Supabase RLS not working? A debugging checklist
When the app returns empty data or permission errors after you tighten policies, debug in this order. Do not jump to using (true). That "fix" is how open tables ship.
- The table. In the Dashboard under Policies (or with SQL), confirm row level security is on for that relation. If the table was created by migration, assume it was off until you prove otherwise. List every table the app reads; agents often secure
profilesand leaveordersopen. - The role. Is the client sending a user JWT, or only the anon key? A policy
to authenticatedwill never match a signed-out request. Log the role your request actually uses. A stuck "logged in" UI with an expired token looks like a policy bug and is not one. - The policy. For the exact operation (
select/insert/update/delete), is there a policy for that role? Doesusing/with checkreference the columns you think? Prefer(select auth.uid()) = user_idover patterns that hide mistakes. Check you did not leave an olderusing (true)policy beside a stricter one. - The client. Wrong schema, wrong table name, or a second Supabase project in
.envwill look like "RLS is broken." Confirm the URL and the publishable key match the project you edited. Confirm you are not accidentally using the service role in a server action that mirrors into the browser.
Test with the anon key alone: select on a sensitive table must not return other people's rows. Then test with a logged-in token for user A and request user B's id. If either test succeeds when it should fail, stop shipping features and fix the policy. The wider repair order for a vibe-coded product is in fix a vibe-coded app.
How to test before you launch
Run Supabase's security advisor on the project and read every finding on exposed tables, policies, and keys. If you are on Lovable, run the built-in pre-publish security scan and treat recommendations as blockers, not suggestions. Lovable's CISO told The Register that users must implement those recommendations before publish; the app in that report had not. A green "I clicked through the scan" checkbox is not the same as a closed finding.
Then do a manual test no scanner replaces:
- Create two real users in the same environment you will ship.
- As user B, try to
select,update, anddeleteuser A's row by primary key through the same client the app uses — not only through the SQL editor as the table owner. - Repeat with no session, only the anon key, against every table that holds PII, payments, or tenant data.
- Open the built frontend bundle and search for
service_roleand other private secrets. Rotate anything that was ever shipped.
The same tool that wrote the policies should not be the only reviewer. Agents optimize for "the screen loads." A second pair of eyes, or a fixed-scope review, is what catches using (true) and inverted auth checks. That is the practical bar for supabase rls policy examples in production: examples that fail the two-account test are not done.
When to bring in a developer
Bring someone in when you cannot explain which policies protect which tables, when a scan still fails, when a second-account test already failed, or when real users and real money are already on the project. A repair is usually policies, grants, key rotation, and tests — not a rewrite of every screen. If you want that pass scoped and priced, book a scope call. Thirty minutes with a founder: we say what is open, what to close first, and which package fits, or that none does.
If this is your app, send the repo.
A 30-minute scope call. We read what is live, name what is dangerous, and quote a fixed scope.
How Techtribe takes a Supabase RLS repair from here
We read the repo and the Supabase project with two accounts and the anon key. You get the open tables and bad policies in writing, then a fixed price to close the ones that matter. Working product flows stay. We do not invent a rebuild to grow the invoice.
You own the code and the project. The scope call is 30 minutes. Bring the URL, the Supabase project access you are willing to share, and say up front if a key or a table may already be public so we treat it as burned.
Have a Supabase app in production? Book a scope call.
Questions
Is Supabase RLS enough on its own?
No. RLS is the row filter for the Data API. You still need grants limited to the right roles, no service_role key in the browser, storage and view policies that match the tables, and a person who tests with a second account before launch.
Does Lovable enable RLS by default?
Lovable ships a free security scan before publish and recommends fixes. Whether RLS is on and correct depends on the generated SQL and on whether you applied those recommendations. Tables created through SQL still need an explicit enable unless the migration did it.
How do I know if my AI-generated app exposed Supabase data?
Call the REST endpoint for a sensitive table with only the anon key and no session. If you get other users' rows, the table is open. Then try the same call as a second logged-in user with the first user's id. A 200 on someone else's row is a leak.
Can I fix RLS without rewriting the app?
Yes, when the product flow is sound. You enable RLS, replace open policies, add with check, move service_role server-side, and retest. The screens stay. The database rules change. A rewrite waits until the schema itself cannot be saved.