Common Row Level Security Mistakes That Leak Data in Supabase
Codoric Team

Common Row Level Security Mistakes That Leak Data in Supabase
Row Level Security is one of Supabase's strongest features — policies live in the database itself, so authorization can't be bypassed by forgetting a check in application code. That strength has a flip side: a mistake in a policy is also in the database itself, silently exposing rows to every client that queries the table. These are the specific mistakes that show up repeatedly in real projects, well past the "getting started" tutorials.
Mistake 1: Enabling RLS without actually adding a policy
This is the most common one, and the most dangerous, because the failure mode is invisible in casual testing.
alter table public.orders enable row level security;
On its own, this does not restrict access to zero rows the way you might assume — the actual default behavior depends on whether any policy exists for the operation being performed. If a table has RLS enabled but no SELECT policy defined at all, reads return zero rows for anon/authenticated roles (safe by default). But teams often add a policy for SELECT and forget one for UPDATE or DELETE — and depending on how the table was set up, that gap can behave inconsistently across roles. The actual rule to internalize: enabling RLS is not the security boundary — having an explicit, correct policy for every operation your app performs is. Always verify each operation (select, insert, update, delete) has its own policy, and don't assume "I added a policy" covers the whole table.
Mistake 2: Using USING without WITH CHECK on the same policy
For UPDATE policies, USING controls which existing rows a user can target, but WITH CHECK controls what the row is allowed to look like after the update. Missing WITH CHECK is a classic way to let a user update a row they legitimately own, but change a column (like user_id) to something they shouldn't be able to set.
-- Incomplete: user can reassign the order to someone else
create policy "Users can update their own orders"
on public.orders for update
using (auth.uid() = user_id);
-- Correct: user can update their own orders, but can't change ownership
create policy "Users can update their own orders"
on public.orders for update
using (auth.uid() = user_id)
with check (auth.uid() = user_id);
Without with check, a user could run an update that sets user_id to someone else's ID, effectively transferring the row out from under RLS's protection for their own future queries, or worse, into a state the application never expected.
Mistake 3: Policies that reference the wrong role
Supabase's default roles are anon (unauthenticated) and authenticated (logged in). A policy written without specifying to authenticated applies to public, which includes both roles:
-- Applies to anon AND authenticated — probably not intended
create policy "Users can view their own profile"
on public.profiles for select
using (auth.uid() = id);
For an anonymous request, auth.uid() is null, so this specific example happens to fail safe (no row matches a null comparison) — but relying on that being true for every policy you write is fragile. Explicitly scoping policies with to authenticated (or to anon where genuinely intended) makes the intent unambiguous instead of depending on how a particular condition happens to evaluate for the wrong role.
Mistake 4: Forgetting that service_role bypasses RLS entirely
The service role key bypasses Row Level Security completely — by design, for backend/admin operations. The mistake is using the service role key in contexts where it doesn't belong: a client-side bundle, a serverless function that just proxies user requests without adding its own authorization checks, or a webhook handler that trusts the payload without verifying it. Every place the service role key is used needs its own authorization logic, because RLS isn't providing any protection there at all.
Mistake 5: Testing only as the row owner, never as a different authenticated user
The most common way an RLS bug reaches production: it was tested by the developer, logged in as themselves, looking at their own data — which of course works, RLS wasn't stopping their own access in the first place. The actual test that catches leaks is: log in as User A, then try to query, update, or delete User B's rows directly via the API (not through the app's UI, which won't offer that option — via a raw request). If that succeeds, there's a policy gap.
// Test as User A, targeting User B's known row ID
const { data, error } = await supabaseAsUserA
.from('orders')
.select('*')
.eq('id', userBsOrderId);
// data should be empty, not User B's order
This is worth writing as an actual automated test, not a manual one-time check, because a future migration or a new policy added for a different feature can silently reopen a gap that was previously closed.
Mistake 6: Complex policies that are correct but unreadable
A policy that joins across three tables to determine access is technically expressible in SQL, and technically correct on the day it's written — and a genuine liability six months later when someone modifies a related table and doesn't realize a policy depends on its exact shape. Where possible, extract the authorization logic into a security definer function with a clear name, so the policy itself reads as intent rather than as a puzzle:
create function public.user_can_access_order(order_id uuid)
returns boolean
language sql
security definer
as $$
select exists (
select 1 from public.orders o
join public.teams t on t.id = o.team_id
join public.team_members tm on tm.team_id = t.id
where o.id = order_id and tm.user_id = auth.uid()
);
$$;
create policy "Team members can view team orders"
on public.orders for select
using (public.user_can_access_order(id));
This doesn't change the security properties, but it makes the policy reviewable at a glance, and testable independently of the policy syntax.
The underlying habit
Every mistake above comes from treating RLS policies as a one-time setup step instead of an ongoing part of the schema that gets reviewed alongside every migration that touches a protected table. Row Level Security is powerful precisely because it's enforced at the database layer regardless of which client or endpoint makes the request — which also means a gap there is a gap for every access path at once, not just one forgotten if statement in one route handler.
If you want a second pair of eyes on your RLS policies before or after launch, an authorization review is one of the highest-leverage things I do for Supabase projects — book a call.