Skip to content
All guides

Security3 min read

Supabase row-level security: a checklist for vibe-coded apps

Why RLS is the most common hole in AI-built Supabase apps, how to tell if yours is exposed, and the policies most apps actually need.

Supabase is the default backend for a lot of AI-built apps, and for good reason. But it has one setting that, when it's off, exposes your entire database to anyone who opens your site: row-level security (RLS).

This guide explains why that happens, how to check your app in five minutes, and the policies most apps actually need.

Why this is the most common hole

Supabase gives your frontend a public anon key. That's by design: it's meant to be visible in the browser. With it, anyone can call your database through Supabase's automatic API.

What keeps your data safe is RLS. When RLS is enabled on a table, every request is checked against policies you write, such as "users can only read their own rows". When RLS is disabled, the anon key can read and often write every row in that table.

AI tools frequently create tables and skip the policies, especially when you ask them to "just make it work".

Check your tables in five minutes

In the Supabase dashboard, open the SQL Editor and run:

select tablename, rowsecurity
from pg_tables
where schemaname = 'public'
order by tablename;

Any table where rowsecurity is false is open. The Table Editor and the Security Advisor in the dashboard will also flag tables without RLS.

Turn RLS on

For each table, enable RLS:

alter table public.profiles enable row level security;

With RLS on and no policies, nobody using the anon or signed-in keys can read or write that table. That's a safe starting point: your app may break until you add policies, which tells you exactly which access it needs.

The policies most apps need

Most tables in a typical app belong to a user. For a table with a user_id column that references the signed-in user, these four policies cover the basics:

create policy "Read own rows"
on public.projects for select
to authenticated
using ( (select auth.uid()) = user_id );

create policy "Create own rows"
on public.projects for insert
to authenticated
with check ( (select auth.uid()) = user_id );

create policy "Update own rows"
on public.projects for update
to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );

create policy "Delete own rows"
on public.projects for delete
to authenticated
using ( (select auth.uid()) = user_id );

using decides which existing rows a user can touch. with check decides what a user is allowed to write. Update policies need both, or a user could move a row to someone else.

Wrapping auth.uid() in select is a small performance optimisation Supabase recommends for policies on larger tables.

Mistakes we see all the time

  • using (true) on a select policy. That's the same as having no RLS for reads.
  • A select policy but no update policy, then the AI "fixes" the resulting error by disabling RLS.
  • Trusting a column the user can write. If a user can update their own role or plan column, they can make themselves an admin.
  • Forgetting storage. Files in Supabase Storage have their own policies on storage.objects. Public buckets are public to everyone.
  • Views and functions that bypass RLS. Views run with their creator's permissions by default, and security definer functions skip RLS. Use them deliberately.
  • Using the service_role key in the frontend to make an error go away. That disables every protection at once.

Test it like an attacker

Policies are code, and code needs testing. Create two test users, sign in as one, and try to read, update, and delete the other's rows, both through your app and by calling the API directly with the anon key. If anything succeeds that shouldn't, your policies need work.

Want a second pair of eyes?

RLS mistakes are quiet: everything works perfectly until someone looks. Our free Preflight check catches database tables that are openly readable from outside, and a Scan reviews every policy in your project.