Every AI coding tool — Lovable, Bolt, v0, Cursor — is fluent in Supabase. It will wire up auth, generate tables, and produce a working CRUD app in minutes. What it will not reliably do is make that app safe to point at the internet.
We have audited a lot of these codebases. The same five Row Level Security (RLS) mistakes appear again and again, and every one of them is exploitable with nothing more than the anon key that ships inside your JavaScript bundle.
1. RLS is never enabled at all
The big one, and depressingly common. The tool creates a table, the app works, and nobody runs the single statement that turns the lock on.
With RLS off, the anon key reads and writes every row in that table. Check all of them at once:
select
schemaname,
tablename,
rowsecurity as rls_enabled
from pg_tables
where schemaname = 'public'
order by rls_enabled, tablename;Every false is a table the internet owns. Fix it one table at a time:
alter table public.profiles enable row level security;Enabling RLS with no policies denies everything, which will break your app loudly. That is the correct order of operations: break it, then open exactly the doors you need. An app that works because nothing is locked is not working.
2. The using (true) policy
The second most common pattern is a policy that exists and protects nothing:
-- Looks like security. Is not security.
create policy "enable read access for all users"
on public.orders for select
using (true);This is the default snippet in Supabase's own policy templates, and every AI tool
has read it. On a public posts table it is correct. On orders, messages, or
subscriptions it means any visitor can page through every record you hold.
Scope it to the row's owner, and name the role it applies to:
create policy "users read their own orders"
on public.orders for select
to authenticated
using ((select auth.uid()) = user_id);Two details in there earn their place. to authenticated stops the policy being
evaluated for anonymous requests at all. And wrapping the call as
(select auth.uid()) lets Postgres evaluate it once for the whole query instead
of once per row — on a large table that difference is not subtle.
3. select is protected, update and delete are not
Policies are per-operation. A for select policy says nothing about writes, and a
table with only a read policy will reject writes — until somebody "fixes" that by
adding a permissive for all.
We regularly find tables where reads are correctly scoped and update is wide
open: an attacker cannot see your data, but can happily destroy it. Audit what
actually exists rather than what you remember writing:
select tablename, policyname, cmd, roles, qual, with_check
from pg_policies
where schemaname = 'public'
order by tablename, cmd;You need a policy per operation you intend to allow, and with_check matters as
much as using:
| Clause | Applies to | The question it answers |
|---|---|---|
using | select, update, delete | Which existing rows may I touch? |
with_check | insert, update | What am I allowed to write? |
An update policy with a using clause and no with_check lets a user edit
their own row and reassign its user_id to somebody else on the way out. The
read policy was never the problem.
4. Trusting a column the client controls
This one is subtle, and it is our favourite to find, because the code looks careful:
create policy "admins do anything"
on public.posts for all
using ( (select role from public.profiles where id = (select auth.uid())) = 'admin' );Reasonable — unless profiles.role is itself writable by its owner. If that
table has a permissive update policy, any account promotes itself to admin with
one request and then walks through this policy entirely legitimately.
Privilege data has to live somewhere the user cannot write. Put it in a table
with no user-facing write policy at all, and read it through a
security definer function:
create or replace function public.is_admin()
returns boolean
language sql
stable
security definer
set search_path = ''
as $fn$
select exists (
select 1 from public.admins a where a.user_id = (select auth.uid())
);
$fn$;
revoke all on function public.is_admin() from public;
grant execute on function public.is_admin() to authenticated;set search_path = '' is not decoration. Without it, a security definer
function can be redirected to an attacker-controlled table through the search
path — a privilege-escalation bug in the thing you wrote to prevent privilege
escalation. Fully qualify every name inside the body, as above.
5. The service-role key in the browser
Finally, the mistake that makes the other four irrelevant. The service_role key
bypasses RLS completely, by design. It belongs on a server, in a secret store,
and nowhere else.
Anything named NEXT_PUBLIC_*, VITE_*, or PUBLIC_* is compiled into your
client bundle. Grep before every deploy:
grep -rInE 'service_role|SUPABASE_SERVICE' src/ app/ components/ .env* 2>/dev/nullA hit anywhere a browser can reach means rotating the key in Supabase → Settings → API, immediately, and treating everything it could touch as read. How to find the keys your AI tool leaked walks the full five-minute version of that audit, including the git history — a key removed in a later commit is still in the repository.
Proving a policy actually works
Reading a policy and believing it is how most of these bugs survive review. Test it the way the database will see it, by becoming the role:
-- Pretend to be a signed-in user, in a transaction you can throw away.
begin;
select set_config('request.jwt.claims',
'{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated"}', true);
set local role authenticated;
select count(*) from public.orders; -- expect: only their rows
update public.orders set total_cents = 1; -- expect: 0 rows affected
rollback;Then run the same block as anon with no claims set. The test that matters is
not "my app still works" — it is "the request I am not supposed to be able to
make comes back empty". Write the malicious query first and watch it fail.
Two more checks worth building into that habit:
- Storage is a separate policy surface. Buckets have their own RLS on
storage.objects, and a public bucket is genuinely public — including the files someone uploaded to a private-looking part of your app. - Grants still exist underneath. RLS filters rows; it does not remove a
table-level
grant. Ifanonhas no business touching a table at all,revokeits privileges as well and let RLS be the second line rather than the only one.
A 10-minute self-audit
Run these five against your project right now:
- Every public table has RLS on — the
pg_tablesquery returns nofalse. - No bare
using (true)on anything user-specific. - Explicit policies per operation, and every
insertorupdatepolicy has awith_check. - Privilege flags live in a table users cannot write, read through a
security definerfunction with an emptysearch_path. - No service-role key in any client-reachable file or
NEXT_PUBLIC_variable.
If any of those fail, you have a live data-exposure bug rather than a future one.
Where this sits in a launch
RLS is the first section of a longer list, and it is first because it is the only one where the failure mode is "a stranger has your customers' data" rather than "the app is slow". The rest of that list — auth flows, storage policies, secret handling, rate limits, error reporting — is written up in the launch checklist we run before any AI-built app goes live, and what to fix before you launch puts it in the order we work through it.
Want a second pair of eyes on yours? A free 15-minute launch audit is where that starts: we look at the repo and tell you honestly what stands between you and going live. If you would rather have the whole surface fixed rather than described, that is a production sprint.



