Security9 min read

The Supabase RLS checklist for AI built apps

What row level security in Supabase is, how to switch it on, the policies most apps need, and the checks we run on every table before a launch.

  • 19 Aug 2026
The Supabase RLS checklist for AI built apps

Supabase lets a browser talk to your database directly. That is why AI builders like Lovable and Bolt reach for it, and it is also why row level security matters more on Supabase than almost anywhere else. The browser holds a key, so the database itself has to be the thing that says no.

This is the checklist we run on every Supabase project before it launches, and the first thing we open in a rescue. It covers what RLS is, how to switch it on, the policies most apps need, the ways data leaks around it, and how to test that it works.

What row level security is

Row level security (RLS) is a Postgres feature. When it is on, every query against a table is filtered by rules called policies, and a row only comes back if a policy allows it. Think of a policy as a WHERE clause the database adds to every query, whoever sent it.

Supabase exposes your public schema through an API that anyone with your project URL and anon key can call. Both of those are in your frontend code, which means both are public. Without RLS, that API returns every row of every table to whoever asks.

Step 1. Switch it on for every table

Tables created in the Supabase table editor usually have RLS switched on. Tables created in SQL, in a migration or by an AI tool often do not. Check them all, including the ones added last week.

-- Which tables in the public schema have RLS off?
select tablename
from pg_tables
where schemaname = 'public'
  and rowsecurity = false;

-- Switch it on
alter table public.profiles enable row level security;

A table with RLS on and no policies returns nothing to the anon and authenticated roles. That is the safe starting point. Now you add back exactly the access each table needs.

Step 2. Write one policy per action

Write separate policies for select, insert, update and delete rather than one policy for all of them. It is easier to read, and easier to notice when one is missing. The most common pattern is a user who can only see and change their own rows.

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

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

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

using decides which existing rows a query can see. with check decides which new values it may write. An update policy needs both, or a user can hand their row to somebody else by changing user_id. Wrapping auth.uid() in a select lets Postgres run it once per query instead of once per row, which matters on large tables.

Step 3. Teams and public content

Team access is usually a membership table. The policy asks whether the signed in user belongs to the team that owns the row.

create policy "Members read their team's rows"
on public.documents for select
to authenticated
using (
  team_id in (
    select team_id from public.memberships
    where user_id = (select auth.uid())
  )
);

The memberships table needs RLS of its own, and a policy on memberships that queries memberships will recurse. The usual fix is a small security definer function that returns the current user’s teams, used by both policies. Content everyone may read, like published posts, gets a policy for the anon role that filters on a column only the server can set.

create policy "Anyone reads published posts"
on public.posts for select
to anon, authenticated
using ( published = true );

Step 4. Close the ways around it

Most leaks we find are not a missing policy. They are a path that skips the policies altogether.

  • The service role key bypasses RLS completely. It belongs on the server and nowhere else. In a Vite app, any variable starting with VITE_ ends up in the browser bundle, so search the built JavaScript for the key.
  • Views run with the rights of their owner, usually postgres, so they ignore RLS on the tables underneath. On Postgres 15 and later, create them with security_invoker = true.
  • Security definer functions run with their owner’s rights and can be called through the API as RPC. Give them a fixed search_path, check auth.uid() inside, and revoke execute from anon if a guest should never call them.
  • User metadata can be edited by the user. A policy that trusts a role stored in user_metadata lets anyone make themselves an admin. Keep roles in app_metadata or in a table the client cannot write.
  • Storage buckets have their own policies on storage.objects. A public bucket serves every file in it to anyone with the link.

Step 5. Test it like an attacker

Reading policies is not enough. Ask the API for data you should not get. With only the anon key, which is already in your frontend, request a table directly.

curl 'https://YOUR-PROJECT.supabase.co/rest/v1/profiles?select=*' \
  -H "apikey: YOUR_ANON_KEY"

If that returns rows that are not meant to be public, the table is open. Then sign in as one user and request another user’s row by id. You should get an empty result, not an error and not the row. You can also act as a user in the SQL editor, inside a transaction.

begin;
set local role authenticated;
select set_config('request.jwt.claims',
  '{"sub":"USER-UUID","role":"authenticated"}', true);
select * from public.projects;  -- only this user's rows
rollback;

The Security Advisor in the Supabase dashboard also flags tables in the public schema without RLS. It is a good alarm. It does not tell you whether the policies you wrote are right.

The checklist

  • RLS is on for every table in the public schema
  • Every table has an explicit policy for each action it allows
  • Policies compare against auth.uid(), never a column or metadata the client can set
  • Update policies have both using and with check
  • The service role key is only in server code
  • Views use security_invoker, and security definer functions check the caller
  • Storage buckets have policies, and public buckets hold nothing private
  • A signed in user cannot read another user’s row by changing an id

We run this list before anything else in a rescue, because one missing policy usually means every record in that table has been readable by anyone who looked. It is the cheapest audit in software and the most expensive one to skip.

Let’s build and scale it!