Supabase lets your app's frontend talk to the database directly. That is what makes it so fast to build with, and it is also why one setting decides whether your data is safe: Row Level Security (RLS). The public key in your JavaScript is meant to be public. Anyone can copy it from the browser and send their own queries to your database. The only thing that stops them from reading every row, or changing it, is the set of RLS policies on your tables.
This matters even more for apps built with AI coding tools such as Lovable, Bolt, Cursor or v0. They get the app working quickly, but "working" means it works for you, in the preview, signed in as yourself. In 2025 a security researcher showed that many apps generated by one popular AI app builder exposed their data because the policies were missing or too open (tracked as CVE-2025-48757). Below are the eight mistakes we see most often when we review Supabase projects, how to spot each one, and the SQL to fix it.
First, a two-minute check
Open your project in the Supabase dashboard and go to Advisors → Security Advisor. It flags tables without RLS, views that bypass it and other common problems. Then run these two queries in the SQL editor to see where you stand:
-- which tables in the public schema have RLS switched on
select tablename, rowsecurity
from pg_tables
where schemaname = 'public'
order by tablename;
-- every policy, who it applies to and what it checks
select tablename, policyname, cmd, roles, qual, with_check
from pg_policies
where schemaname = 'public'
order by tablename;
Any table with rowsecurity = false is readable and writable by anyone who has your public key. Any policy whose qual or with_check is just true deserves a second look.
1. RLS is switched off on a table
Tables you create in the dashboard's Table Editor get RLS switched on by default. Tables created with SQL, including migrations written by an AI tool, do not. With RLS off, the API lets anyone select, insert, update and delete every row of that table.
The fix is one line per table. Once RLS is on and there are no policies yet, nobody can reach the table through the API until you add a policy, so you fail safe:
alter table public.profiles enable row level security;
2. Policies that let everyone read everything
The dashboard offers a policy template called "Enable read access for all users". It creates a policy like this:
create policy "Enable read access for all users"
on public.orders for select
using (true);
That is fine for data that really is public, such as blog posts or a product catalogue. On orders, profiles, messages or invoices it publishes the whole table to the internet. Tie each row to its owner instead:
create policy "Users read their own orders"
on public.orders for select
to authenticated
using ((select auth.uid()) = user_id);
Writing (select auth.uid()) rather than auth.uid() is Supabase's own recommendation: Postgres then works out the user once per query instead of once per row, which matters on large tables.
3. Treating "signed in" as "allowed"
A policy that only checks that someone is logged in looks safer than true, but it is not much better:
-- any account can read every customer's data
create policy "Signed-in users can read"
on public.customers for select
to authenticated
using (true);
In most apps anyone can create an account in seconds, so "authenticated" means "anyone with an email address". Check what the user is allowed to see. For data that belongs to a team or a company, check membership:
create policy "Members read their organisation's projects"
on public.projects for select
to authenticated
using (
exists (
select 1 from public.memberships m
where m.org_id = projects.org_id
and m.user_id = (select auth.uid())
)
);
4. Letting users write values they should not control
RLS decides which rows a user can touch. It does not decide which columns. If users can update their own profile row, they can also update every column on it, including is_admin, plan or credits, straight from the browser console. Inserts have the same problem: a policy with with check (true) lets a user create rows that claim to belong to someone else.
Make inserts prove ownership:
create policy "Users create their own orders"
on public.orders for insert
to authenticated
with check ((select auth.uid()) = user_id);
And keep privileged columns out of reach, either in a separate table that only your server writes to, or with column privileges:
revoke update on public.profiles from authenticated;
grant update (display_name, avatar_url) on public.profiles to authenticated;
5. Trusting user_metadata in policies
It is tempting to store a role in the user's metadata and check it in a policy, for example auth.jwt() -> 'user_metadata' ->> 'role' = 'admin'. But user_metadata can be changed by the user themselves: one call to supabase.auth.updateUser({ data: { role: "admin" } }) and the policy waves them through. Put roles in app_metadata, which only your server can set with the secret key, or in a roles table that users cannot write to.
6. Views and functions that skip RLS
Two Postgres features quietly bypass your policies:
- Views created by the default
postgresrole run with that role's rights, so a view over a protected table can return every row through the API. On Postgres 15 and later, make views respect the caller's policies withalter view public.order_summary set (security_invoker = true);, or keep views out of the exposedpublicschema. - Functions marked
security defineralso run with their owner's rights. If such a function sits in thepublicschema, anyone can call it through the API. Use the default (security invoker) unless you really need more, and if you do, put the function in a private schema, setsearch_path = '', and checkauth.uid()inside it.
7. Storage buckets that are more public than you think
Files have their own access rules. A public bucket serves any file to anyone who has its link, and links get shared, logged and guessed. Keep private documents such as invoices, ID scans and contracts in a private bucket, with a policy on storage.objects that ties each file to its owner. A common pattern is one folder per user:
create policy "Users read their own documents"
on storage.objects for select
to authenticated
using (
bucket_id = 'documents'
and (storage.foldername(name))[1] = (select auth.uid())::text
);
When a file needs to be shared, create a signed URL that expires after a few minutes instead of making the bucket public.
8. The secret key in the browser
Everything above assumes the browser only ever has the public key: the "anon" key, or a new-style key starting with sb_publishable_. The service_role key, or a new-style key starting with sb_secret_, bypasses RLS completely. If it ends up in your frontend code, all your policies stop mattering.
It usually gets there through an environment variable with a public prefix, such as NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY or VITE_SUPABASE_SERVICE_KEY, because that made an error go away during development. Older keys are JWTs: paste one into a JWT decoder and look at its role claim. Anon is fine to ship, service_role never is. If a secret key has been in your frontend or in a public repository, roll it in the dashboard first and fix the code second. We explain how to find leaked keys in our guide to exposed API keys in JavaScript.
Test as a stranger, not as yourself
The reason these mistakes survive is that everyone tests their own app signed in as themselves, and everything they are allowed to see shows up as expected. Test the other cases on purpose, against your own project:
- Signed out: request a private table with only the public key. You should get an empty list, not data.
- Signed in as user A: try to read and update user B's rows by changing the ID in the request. Nothing should come back and nothing should change.
- As a normal user: try to set your own role or plan to something better. The update should be refused.
curl "https://YOUR-PROJECT.supabase.co/rest/v1/orders?select=*" \
-H "apikey: YOUR_PUBLIC_KEY"
If that command returns other people's orders, so can anyone else's.
Want a second pair of eyes?
RLS is not hard, but it is easy to get one table or one policy wrong, and one is enough. Our AI app security audit checks your database rules, storage, keys, admin routes and payments the way an attacker would, with your written permission and our own test accounts, and gives you a plain-English report with proof and fixes. For a quick look at your live site first, run the free Website Health Check. If your app was built with an AI tool, also see our pre-launch security checklist for vibe-coded apps, and if part of it runs on Firebase, our Firebase Security Rules guide.



