VibeCheck

Guide · Supabase

Supabase RLS checker: check Row Level Security yourself

If your app uses Supabase, Row Level Security is the line between "my users' data" and "anyone's data". Here is what it does, how to check it with a few SQL queries, and the mistakes that show up most often in AI-generated apps.

What RLS is, in one paragraph

Supabase exposes your Postgres tables through an HTTP API (PostgREST). Your frontend calls it with the anon or publishable key, which is public by design. When a request comes in, Postgres runs it as the anon role, or as authenticated if the request carries a logged-in user's token. Row Level Security is a Postgres feature that filters every query through policies you write. With RLS on and no policy, the API returns nothing. With RLS off, the API returns whatever the role's table privileges allow, and in Supabase that is normally everything.

Tables you create in the dashboard's Table Editor have RLS enabled by default. Tables created with SQL, including SQL an AI builder ran for you, do not, unless the SQL says so.

Step 1: find tables without RLS

select n.nspname as schema, c.relname as table, c.relrowsecurity as rls_enabled
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where c.relkind = 'r'
  and n.nspname = 'public'
order by c.relrowsecurity, c.relname;

Anything with rls_enabled = false at the top of the list needs attention. Turn it on with:

alter table public.profiles enable row level security;

After this, the table returns no rows through the API until you add policies, so add them in the same change or your app will look empty.

Step 2: read your policies

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

For each table ask: who is this policy for (roles), which operation (cmd: SELECT, INSERT, UPDATE, DELETE or ALL), and what does the condition actually restrict? Things to look for:

Step 3: test from the outside

SQL tells you what you configured; a request tells you what actually happens. For your own project, with the anon key from your frontend:

curl -I 'https://YOUR-PROJECT.supabase.co/rest/v1/profiles?select=*' \
  -H "apikey: YOUR_ANON_KEY" \
  -H "Authorization: Bearer YOUR_ANON_KEY" \
  -H "Prefer: count=exact"

The -I asks only for headers. Look at the Content-Range header: */0 means an anonymous visitor sees no rows, while something like 0-24/1240 means anyone on the internet can read 1,240 rows. Repeat for every table that holds anything private.

A good default policy set

For a table where each row belongs to one 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);

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

Wrapping auth.uid() in (select ...) is the form Supabase recommends for performance; it is evaluated once per query instead of once per row. An index on user_id helps too.

Common mistakes

1. "Authenticated" is treated as "trusted"

to authenticated using (true) lets every logged-in user read every row. If anyone can sign up, that is nearly the same as public. Restrict by owner, team membership or a role stored in a table users cannot edit.

2. Missing WITH CHECK

using controls which existing rows a user can see or change; with check controls what the new row may look like. Without it, a user can insert a row with someone else's user_id, or update their own row to belong to someone else.

3. Trusting user metadata

Policies that read auth.jwt() -> 'user_metadata' (for example, a "role" there) are unsafe: users can change their own user metadata. Put roles in app_metadata, which only the server can set, or in a separate table protected by its own policies.

4. Users can update their own role

If a profiles table has an is_admin column and an "update own profile" policy, users can make themselves admin. Keep privilege columns in a separate table, or restrict which columns can be updated with column privileges or a trigger.

5. Views that bypass RLS

By default a Postgres view runs with the privileges of its owner, which usually skips RLS on the tables underneath. On Postgres 15 and later, create views with with (security_invoker = true) so they respect the caller's policies, or keep them out of the exposed schema.

6. SECURITY DEFINER functions

Functions in the public schema can be called through /rest/v1/rpc/. A security definer function runs as its owner and ignores RLS. If it should not be public, revoke execute from anon (and from public), and check inside the function who is calling.

7. Storage has its own policies

Files live in storage.objects, which has separate RLS policies. A table can be locked down while the bucket holding the same users' uploads lets anyone list it.

8. The service_role key in the frontend

The service_role (or sb_secret_) key bypasses RLS entirely. If it is in your frontend bundle, none of the above matters until you rotate it. More in exposed API keys in the frontend.

Checking regularly

RLS problems come back. Each time you ask your builder for a new feature it may create a new table, and new tables made with SQL start without RLS. Re-run the first query after big changes, check the Security Advisor in the Supabase dashboard, or use an outside scan that tests every table your frontend talks to.