If you built your app with Lovable, Bolt or v0, there's a good chance it runs on Supabase. And there's a real chance that anyone on the internet can read your users' data right now.
Not because Supabase is insecure. Because of one setting that's easy to get wrong: Row Level Security (RLS).
This post explains what RLS is, the mistakes that leave data exposed, and how to check your own app in five minutes.
Why your Supabase key is public (and why that's fine)
Supabase gives every project an API that talks directly to your Postgres database. Your frontend calls it with the anon key (newer projects call it the publishable key).
That key is meant to be public. It ships in your JavaScript bundle, and anyone can open DevTools and copy it.
So what stops someone from using your key to read every row in your profiles or orders table?
Only RLS. RLS policies are rules inside Postgres that decide which rows each request is allowed to see or change. If RLS is off, the public key is effectively a master key to that table.
The 5 mistakes I'd look for first
1. RLS is turned off
Tables created in the Supabase dashboard's Table Editor have RLS enabled by default. Tables created with raw SQL or migrations, which is how many AI tools generate them, often don't.
With RLS off, this request returns every row:
curl 'https://YOUR-PROJECT.supabase.co/rest/v1/profiles?select=*' \
-H "apikey: YOUR_ANON_KEY" \
-H "Authorization: Bearer YOUR_ANON_KEY"
2. RLS is on, but the policy allows everything
When you turn RLS on with no policies, Postgres blocks everything and the app "breaks." The quick fix people (and AI tools) reach for is:
create policy "allow all" on profiles
for select using (true);
using (true) means "every row, for everyone." RLS is technically on, but it protects nothing.
3. Reading is protected, but writing isn't
Policies have two parts:
-
using (...)decides which existing rows you can see, update or delete -
with check (...)decides what new or changed rows you're allowed to write
A common gap: a solid select policy, but an insert or update policy without with check. A user can then write rows with someone else's user_id, or update a field like role = 'admin'.
4. The service role key is in the frontend
The service role key (newer projects: secret key) skips RLS completely. It belongs only on a server. If you can find it in your frontend code or .env files that get bundled, rotate it now.
5. Policies trust data the user controls
user_metadata (raw_user_meta_data) can be edited by the user. A policy like "allow if the user's metadata says role = admin" can be bypassed by the user simply editing their own metadata. Store roles in app_metadata or a separate table only your server can write.
What a correct setup looks like
Here's a typical "users only see their own rows" setup for a notes table:
-- 1. Turn RLS on
alter table public.notes enable row level security;
-- 2. Read: only your own notes
create policy "Users can read their own notes"
on public.notes for select
to authenticated
using ( (select auth.uid()) = user_id );
-- 3. Create: only notes that belong to you
create policy "Users can create their own notes"
on public.notes for insert
to authenticated
with check ( (select auth.uid()) = user_id );
-- 4. Update: only your notes, and you can't hand them to someone else
create policy "Users can update their own notes"
on public.notes for update
to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );
-- 5. Delete: only your own notes
create policy "Users can delete their own notes"
on public.notes for delete
to authenticated
using ( (select auth.uid()) = user_id );
-- 6. Keep it fast
create index on public.notes (user_id);
Two details worth knowing:
- Wrapping
auth.uid()in(select ...)lets Postgres evaluate it once per query instead of once per row, which Supabase recommends for performance. -
to authenticatedmeans logged-out visitors get nothing, even before the condition runs.
Check your own app in 5 minutes
Step 1: find tables with RLS off. Run this in the Supabase SQL Editor:
select tablename, rowsecurity
from pg_tables
where schemaname = 'public'
order by rowsecurity, tablename;
Any table with rowsecurity = false is readable with your public key.
Step 2: look for "allow all" policies.
select tablename, policyname, cmd, qual, with_check
from pg_policies
where schemaname = 'public';
Look for qual or with_check equal to true, and for insert/update policies with an empty with_check.
Step 3: open the Security Advisor. In the Supabase dashboard, the Security Advisor flags tables without RLS and other common issues. Fix everything marked as an error.
Step 4: test as a stranger. Run the curl request from mistake #1 against your important tables. If you get real data back without logging in, that table is exposed.
The bigger picture
None of this is specific to AI tools. I worked with the same idea for years with Firebase security rules: the browser can never be trusted, so every permission has to be enforced in the database or on the server. AI builders are great at getting the screens working. They're less reliable at this invisible layer, because a missing policy doesn't show up as a bug until someone finds it.
If your app handles real user data, it's worth an hour to check before launch.
I'm Muhammad Moiz, a full-stack developer who helps founders take AI-built apps to production. If you'd like a second pair of eyes on your Supabase setup, I offer a fixed-price production audit.
Top comments (0)