If your app was built with Lovable, Bolt, Cursor, or a similar tool, there is a good chance it uses Supabase for its database and logins. Supabase is a solid product. But it works in a way that surprises people: your app's browser code talks to the database directly, using a key that every visitor can see.
That is by design, and it is safe, as long as one thing is true: Row Level Security (RLS) is switched on for your tables, with sensible rules. If it isn't, anyone who opens your site's developer tools can read, and often change, everything in those tables.
This post shows you how to check that in about ten minutes. You don't need to be a database expert.
The idea in one minute
- The public key (called the
anonkey, or the publishable key in newer projects) is in your app's code on purpose. Everyone can see it. - Row Level Security is a set of rules inside the database that decide which rows each user may read or change. With it on and the right rules, the public key can only reach what that user is allowed to see.
- With it off, the public key can reach the whole table.
- The secret key (
service_role, or the secret key in newer projects) skips all those rules. It must only ever live on a server, never in the browser.
Step 1: Try to read your own data while logged out
This is the fastest test, and it is exactly what an attacker would do. Replace the three placeholders with your project's address, a table name from your app (for example profiles or orders), and the public key from your project's API settings:
curl "https://YOUR-PROJECT.supabase.co/rest/v1/YOUR_TABLE?select=*&limit=5" \
-H "apikey: YOUR_PUBLIC_KEY" \
-H "Authorization: Bearer YOUR_PUBLIC_KEY"
- You get rows back: that table is open to the whole internet. Stop and fix it before anything else.
- You get an empty list
[]or an error: good, as long as the table actually has data in it. An empty table gives[]whether it's protected or not.
Repeat it for every table that holds anything personal: users, orders, messages, files, settings.
Step 2: List every table that has no protection
In the Supabase dashboard, open the SQL Editor and run:
select tablename, rowsecurity
from pg_tables
where schemaname = 'public'
order by tablename;
Every table with rowsecurity = false has RLS switched off. Tables created through the SQL editor or by a migration often start that way, and AI tools create tables through migrations. The dashboard's Security Advisor also flags these tables, so check it too.
Step 3: Read the rules on the tables that are protected
RLS being on is not enough. Run this to see every rule:
select tablename, policyname, cmd, roles, qual, with_check
from pg_policies
where schemaname = 'public'
order by tablename;
Look for these four problems:
- A rule that is just
true. Ifqualistrue, the rule lets everyone through. It's the AI tool's way of making an error message go away. - Rules for
anonthat write data. Theanonrole is anyone who isn't logged in. Insert, update, and delete rules for it are rarely what you want. - No
with_checkon insert or update rules. Without it, a user can write a row that belongs to somebody else. - Rules that trust
user_metadata. Users can edit their ownuser_metadata. Never base permission on it, for example "is admin". Useapp_metadataor a roles table that only your server can change.
What a correct basic rule looks like
For a table where each row belongs to one user, via a user_id column:
alter table notes enable row level security;
create policy "Users read their own notes"
on notes for select
to authenticated
using ( (select auth.uid()) = user_id );
create policy "Users insert their own notes"
on notes for insert
to authenticated
with check ( (select auth.uid()) = user_id );
create policy "Users update their own notes"
on notes for update
to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );
create policy "Users delete their own notes"
on notes for delete
to authenticated
using ( (select auth.uid()) = user_id );
The pattern is always the same: say who the rule is for, and compare the logged-in user's ID to the owner of the row.
Three other places data leaks
- Views. A view reads its tables with its creator's permissions by default, which skips your rules. On recent Postgres versions, create views with
security_invoker = trueso they respect the caller's rules. - Storage buckets. Files have their own switch: a bucket marked public serves every file in it to anyone with the link. Check that invoices, documents, and uploads are in private buckets with rules of their own.
- The secret key in the browser. Search your deployed site's files for the secret key. If it's there, rotate it in the dashboard first, then remove it from the code. Deleting it from the code alone is not enough.
Fixing this without breaking your app
One trap: switching RLS on for a table that has no rules blocks all access through the public key, including your own app's. The app will look broken. So:
- Write the rules first, ideally on a copy of your database or a test project.
- Switch RLS on and test with two separate accounts: user A creates something, then user B must not be able to see or change it.
- Repeat the logged-out test from Step 1.
- Only then apply the change to your live project.
Your AI tool can write these rules for you. Treat what it writes as a draft: read every rule, and run the tests above. The mistake to watch for is a rule written to silence an error rather than to protect the data.
Where this fits
Database rules are check 9 of 33 in the full pre-launch checklist. The others cover secrets, logins, payments, and costs. If you'd rather have an engineer go through all of it and hand you a ranked list of what to fix, that's the launch-readiness audit.