DEV Community

Mason Roy
Mason Roy

Posted on

6 Supabase RLS Policies That Pass Code Review and Still Leak Data

TL;DR

  • enable row level security with zero policies is safe. Forgetting to enable it is a public table. Check every table, not just the ones you remember creating.
  • using is for reading rows. with check is for writing them. An update policy with only using lets users move rows to someone else.
  • Never put authorization data in raw_user_meta_data. The user can edit it. Use app_metadata or a separate table.
  • to public includes anon. Say to authenticated when you mean logged-in users.
  • Views and security definer functions bypass RLS silently. Audit them.
  • Wrap auth.uid() in (select auth.uid()) or your policies will be slow at scale.
  • Every one of these is testable. If your RLS isn't tested, it's a hope.

Row Level Security is the reason Supabase works from a mobile client at all. Your anon key is in the app bundle, anyone can extract it, and the only thing between them and your orders table is a policy you wrote in SQL at 11pm.

I've reviewed a lot of RLS. The scary ones aren't the obviously wrong policies — those get caught. The scary ones are the ones that read correctly, pass review, and leak in a way that only shows up when someone tries. Here are the six I see most, with the fix and the test.

1. The table you forgot to enable RLS on

Not a bad policy. No policy — because RLS was never turned on.

create table profiles (
  id uuid primary key references auth.users,
  display_name text,
  email text
);
-- ...and nothing else
Enter fullscreen mode Exit fullscreen mode

This table is readable and writable by anyone with the anon key. Supabase's dashboard warns you, but the dashboard isn't in your CI. Migrations written by hand (or by an AI) skip this constantly.

The fix is to make it structurally impossible to forget:

-- Put this in your first migration and never remove it
create or replace function public.tables_without_rls()
returns table(table_name text) language sql stable as $$
  select c.relname::text
  from pg_class c
  join pg_namespace n on n.oid = c.relnamespace
  where n.nspname = 'public'
    and c.relkind = 'r'
    and not c.relrowsecurity;
$$;
Enter fullscreen mode Exit fullscreen mode

The test:

test('every public table has RLS enabled', async () => {
  const { data } = await admin.rpc('tables_without_rls');
  expect(data).toEqual([]); // prints the offending table name when it fails
});
Enter fullscreen mode Exit fullscreen mode

Run it on every migration. This one test has caught more real leaks for me than the other five combined.

2. update with using but no with check

This is the one that reads correctly.

create policy "users update own profile" on profiles
  for update
  using (auth.uid() = id);
Enter fullscreen mode Exit fullscreen mode

using decides which existing rows the user can touch. with check decides what the row is allowed to look like after the write. With only using, a user can select their own row — and then update its id to someone else's, or on a table with a user_id column, reassign ownership entirely:

// Alice, logged in, doing something she shouldn't be able to
await supabase
  .from('posts')
  .update({ user_id: BOB_ID })   // now Bob owns Alice's spam post
  .eq('id', alicesPostId);        // passes `using`, no `with check` to stop it
Enter fullscreen mode Exit fullscreen mode

The fix:

create policy "users update own posts" on posts
  for update
  using (auth.uid() = user_id)
  with check (auth.uid() = user_id);
Enter fullscreen mode Exit fullscreen mode

The test:

test('cannot reassign a row to another user', async () => {
  const alice = await signInAs('alice');
  const { error } = await alice
    .from('posts')
    .update({ user_id: BOB_ID })
    .eq('id', alicesPostId);
  expect(error?.code).toBe('42501'); // insufficient_privilege
});
Enter fullscreen mode Exit fullscreen mode

Same applies to insert — with check is the only clause that matters there, and a missing one means users can insert rows with any user_id they like.

3. Authorization from raw_user_meta_data

Someone wants an admin flag. The fastest place to put it is user metadata, because it's right there in the JWT:

create policy "admins see everything" on orders
  for select
  using ((auth.jwt() -> 'user_metadata' ->> 'role') = 'admin');
Enter fullscreen mode Exit fullscreen mode

The user can update their own user_metadata through the client:

await supabase.auth.updateUser({ data: { role: 'admin' } });
// refresh session, now they're an admin
Enter fullscreen mode Exit fullscreen mode

That's it. That's the whole exploit.

The fix: app_metadata is only writable with the service role, so it's safe to trust. Better still, keep roles in a table and join:

create table user_roles (
  user_id uuid primary key references auth.users,
  role text not null check (role in ('user', 'admin'))
);
alter table user_roles enable row level security;
-- deliberately no policies: nobody can read or write this from the client

create policy "admins see all orders" on orders
  for select
  using (
    exists (
      select 1 from user_roles
      where user_id = (select auth.uid()) and role = 'admin'
    )
  );
Enter fullscreen mode Exit fullscreen mode

The test:

test('user cannot self-promote via metadata', async () => {
  const mallory = await signInAs('mallory');
  await mallory.auth.updateUser({ data: { role: 'admin' } });
  await mallory.auth.refreshSession();
  const { data } = await mallory.from('orders').select('id');
  expect(data).toHaveLength(0); // still sees nothing
});
Enter fullscreen mode Exit fullscreen mode

4. to public when you meant to authenticated

create policy "read published posts" on posts
  for select
  using (published = true);
Enter fullscreen mode Exit fullscreen mode

No to clause means to public, which includes the anon role. Sometimes that's intended — published posts probably should be public. But copy that pattern to a table where it isn't:

create policy "read own notifications" on notifications
  for select
  using (auth.uid() = user_id);
Enter fullscreen mode Exit fullscreen mode

Here anon gets auth.uid() as null, null = user_id is null, and the policy fails closed. Fine — by accident. Change the policy to use coalesce or a not exists, or add a or public = true branch, and suddenly the accident stops working.

The fix is to be explicit every time:

create policy "read own notifications" on notifications
  for select
  to authenticated
  using ((select auth.uid()) = user_id);
Enter fullscreen mode Exit fullscreen mode

The test:

test('anon cannot read notifications at all', async () => {
  const anon = createClient(URL, ANON_KEY);
  const { data, error } = await anon.from('notifications').select('id');
  expect(data ?? []).toHaveLength(0);
});
Enter fullscreen mode Exit fullscreen mode

5. The view that bypasses everything

Views in Postgres run with the permissions of their owner by default, not the caller. The owner is usually postgres, which bypasses RLS.

create view order_summaries as
  select o.id, o.total, u.email
  from orders o join auth.users u on u.id = o.user_id;
Enter fullscreen mode Exit fullscreen mode

Every authenticated user can now select * from order_summaries and get every order and every email in the system. The orders policies are fine. The view doesn't use them.

The fix (Postgres 15+):

create view order_summaries
  with (security_invoker = true)
as
  select o.id, o.total, u.email
  from orders o join auth.users u on u.id = o.user_id;
Enter fullscreen mode Exit fullscreen mode

security_invoker makes the view run as the caller, so the underlying RLS applies. Same story for functions — security definer functions bypass RLS, which is sometimes exactly what you want (the tables_without_rls helper above needs it), and sometimes a hole. Audit every one:

select p.proname
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.prosecdef;
Enter fullscreen mode Exit fullscreen mode

The test:

test('order_summaries respects caller RLS', async () => {
  const alice = await signInAs('alice');
  const { data } = await alice.from('order_summaries').select('id');
  expect(data?.every(r => aliceOrderIds.includes(r.id))).toBe(true);
});
Enter fullscreen mode Exit fullscreen mode

6. auth.uid() without the select

This one doesn't leak. It's here because it's the one that makes people remove RLS when the table gets big.

using (auth.uid() = user_id)
Enter fullscreen mode Exit fullscreen mode

Postgres calls auth.uid() once per row. On a 5-million-row table with a user_id index, that's 5 million function calls before the index is even considered.

using ((select auth.uid()) = user_id)
Enter fullscreen mode Exit fullscreen mode

Wrapped in a subselect, Postgres evaluates it once and treats it as a constant, so the index is used. On a table I looked at last month this was the difference between 3.2 seconds and 4 milliseconds. Nobody had leaked anything; they'd just been about to turn RLS off "temporarily" for performance.

Same rule for auth.jwt() and anything else that's stable within a query.

Putting it in CI

All six tests together are about 80 lines. They need a real Postgres with your migrations applied and a way to sign in as different test users — which is a one-line supabase start in CI, or a lighter single-process alternative if you don't want Docker there.

The pattern I use:

// test/rls.setup.ts
export async function signInAs(name: string) {
  const client = createClient(URL, ANON_KEY);
  await client.auth.signInWithPassword({
    email: `${name}@test.local`,
    password: 'test-password',
  });
  return client;
}
Enter fullscreen mode Exit fullscreen mode

Seed the users in seed.sql, write one test per policy, run on every PR. The tests are boring. That's the point — the day one of them goes red is the day you didn't ship a leak.

If you're starting from a template rather than a blank project, check whether it ships with these. The AppLighter Expo + Supabase foundation is the one I've seen that has the RLS test harness in it out of the box — the tables_without_rls check and per-policy tests wired into CI — which matters more than any single policy, because it's the thing that catches the seventh mistake I haven't found yet.


Which of these have you hit? And more usefully — which one am I missing? Drop it in the comments and I'll add it with a test.

Top comments (0)