TL;DR
-
enable row level securitywith zero policies is safe. Forgetting to enable it is a public table. Check every table, not just the ones you remember creating. -
usingis for reading rows.with checkis for writing them. Anupdatepolicy with onlyusinglets users move rows to someone else. - Never put authorization data in
raw_user_meta_data. The user can edit it. Useapp_metadataor a separate table. -
to publicincludesanon. Sayto authenticatedwhen you mean logged-in users. - Views and
security definerfunctions 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
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;
$$;
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
});
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);
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
The fix:
create policy "users update own posts" on posts
for update
using (auth.uid() = user_id)
with check (auth.uid() = user_id);
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
});
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');
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
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'
)
);
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
});
4. to public when you meant to authenticated
create policy "read published posts" on posts
for select
using (published = true);
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);
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);
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);
});
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;
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;
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;
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);
});
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)
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)
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;
}
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)