Supabase RLS Gotchas: 7 Patterns That Bite in Production
Row Level Security is Supabase's most powerful feature — and the most common source of silent production bugs. Seven patterns we see break real apps, and what to do instead.
Row Level Security (RLS) is the feature that makes Supabase actually safe to ship a multi-tenant app on. It's also the feature most teams quietly get wrong — usually in ways that don't surface until a customer reads someone else's data, or until your dashboard starts timing out at 50k rows.
Most of these issues show up on teams that are new to Postgres — often after a Firebase → Supabase migration where RLS replaces Firestore security rules.
Here are seven patterns we run into when we audit Supabase projects, with the fixes we apply.
1. Forgetting that auth.uid() is null from the service role
The most common bug. You write a policy like:
create policy "users read own profile"
on profiles for select
using (id = auth.uid());
This works in your app. Then you run a server-side script with the service_role key — and your query returns nothing. Why? auth.uid() returns null from the service role, and id = null is never true.
Fix: the service role bypasses RLS by design. If you're running with the service role and expect rows back, that's correct — but never expose the service role key client-side. From the anon/authenticated client, your policy is fine.
2. Using auth.uid() in a subquery without a stable wrapper
create policy "members read their org"
on orgs for select
using (id in (select org_id from memberships where user_id = auth.uid()));
Looks fine. But Postgres can re-execute auth.uid() per row, and the subquery is correlated. On a large memberships table this gets slow fast.
Fix: wrap it in a security definer helper function that returns the user's org IDs, and mark it stable. Postgres will cache the result for the query.
create or replace function auth.user_org_ids()
returns setof uuid
language sql stable security definer
as $$
select org_id from memberships where user_id = auth.uid()
$$;
create policy "members read their org"
on orgs for select
using (id in (select auth.user_org_ids()));
3. Indexing the wrong column for the policy
If your policy filters by org_id = auth.user_org_id(), but your table is only indexed on created_at, every read is a sequential scan. RLS doesn't slow queries — bad indexing under RLS does.
Fix: after writing each policy, look at the EXPLAIN for the queries that hit it. Add the index the policy actually needs.
4. Writing policies on select but forgetting insert / update / delete
for select is the one everyone writes. Then someone calls update from the client and it... works. Because there's no policy, so the default behavior depends on whether RLS is even enabled.
Fix: for any table with RLS, write all four policies, even if they're permissive. Be explicit.
5. with check versus using
using filters which rows are visible. with check validates rows being written. These are different. A policy with using only will let you write rows you can't read.
Fix: for insert, always use with check. For update, use both using (to filter which rows you can update) and with check (to validate the new values).
6. Helper functions that aren't security definer
A function called from a policy runs as the calling user by default — which means it's also subject to RLS on the tables it touches. If your helper queries memberships to figure out access, and memberships itself has RLS, you can get into infinite-recursion or silent empty-result loops.
Fix: mark policy helpers security definer and lock down their search path. Document which tables they read so future-you doesn't accidentally add RLS to them.
7. No tests
The number of Supabase projects we audit that have zero tests for their RLS policies is depressing. RLS bugs are silent. You won't see them in your dev environment because you're logged in as you. You'll see them on a support ticket from a customer asking why they can see another company's invoices.
Fix: write pgTAP tests, or at minimum write a test harness in your app that logs in as different users and asserts what they can read. Run it in CI. The setup takes an afternoon; the bug it catches will save you a customer.
If your Supabase project has any of these patterns and you'd rather not find out the hard way which ones bite, send us a message — RLS audits are one of our most common engagements.
Related reading:
- Migrating from Firebase to Supabase: A Practical Playbook — how we set up RLS during a migration.
- How Much Does a Supabase Consultant Cost in 2026? — what an RLS audit costs and what's included.