All notes
Kaveen Hyacinth · October 9, 2026 · 3 min

Supabase row-level security, the step AI builders skip

With Supabase, the anon key is public and RLS is your backend. How to find tables that are open to anyone, and the policies that actually close them.

Supabase gives the browser a public anon key and lets it query Postgres directly. That is a good design, but it only holds if row-level security (RLS) does the job a backend would normally do. When RLS is off, or a policy is too loose, anyone who opens the network tab can read or change every row in the table using the same key your app ships with.

AI app builders get the queries right and the policies wrong surprisingly often. Here is how I check.

Find the open tables

Run this in the SQL editor:

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

Any row with rowsecurity = false in an exposed schema is readable and writable through the API. Tables created with raw SQL or migrations don't get RLS switched on automatically, so this is common in generated projects. The Security Advisor in the dashboard flags the same thing.

Then list the policies:

select tablename, policyname, cmd, roles, qual, with_check
from pg_policies
where schemaname = 'public';

Look for qual or with_check set to true. A policy that says using (true) for the anon or authenticated role is the same as no RLS at all, just with extra steps.

Policies that actually scope rows

For a table where each row belongs to a user:

alter table public.notes enable row level security;

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

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

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

Two details matter here. Updates need both using (which rows you can target) and with check (what the row may look like afterwards), or a user can move their row to someone else's user_id. And wrapping auth.uid() in a select lets Postgres evaluate it once per query instead of once per row, which keeps policies fast on large tables. Index user_id too.

The less obvious holes

  • The service role key bypasses RLS entirely. It belongs on the server only. If it appears in the client bundle, RLS doesn't matter.
  • Views run with the permissions of their owner by default, which skips RLS on the underlying tables. On Postgres 15 and later, create them with with (security_invoker = true).
  • security definer functions also run as their owner. Keep them out of exposed schemas, or check the caller inside the function.
  • Storage buckets have their own policies on storage.objects. A public bucket for user uploads means every upload has a guessable public URL.

Test RLS as the user, not as the admin. Sign in as one test user in the app and try to read another user's rows with the anon key. The SQL editor runs with elevated privileges, so it will happily show you everything.

On Cairn every table has RLS on from the first migration, and the policies are part of the spec, not an afterthought. It is far cheaper to start that way than to retrofit it after launch.

Got a demo that needs to survive real users?

Tell me what you have and what has to ship, on a call or by email with a link to the repo or the live app. I'll tell you honestly whether I can help, and what I'd look at first.

Free, 30 minutes, no preparation needed.

Or write tomailme@kaveenhyacinth.com