Your Supabase RLS policy isn't scoped to the user: the missing auth.uid()
This is the sneaky cousin of the USING (true) bug. With USING (true) it's at least obvious the policy does nothing. This case is worse, because it looks like it's doing something. There's a real condition. It filters rows. It passes review. And it still hands one user's data to every other user.
The reason is simple once you see it: the policy's predicate filters on a property of the row, not on the identity of the caller. It never references auth.uid(). So Postgres applies the filter — and then returns the result to whoever asked.
Why a "filter" can still leak everything
A Postgres RLS policy is a per-row boolean. For each row a query touches, the USING expression is evaluated; if it returns true, the caller sees that row. The mental trap is assuming the expression is about the caller. It isn't, unless you make it so.
Look at this policy:
-- supabase/migrations/20260619_invoices_rls.sql
alter table public.invoices enable row level security;
create policy "Members can read invoices"
on public.invoices
for select
using (status = 'active');
- Warningsupabase/migrations/20260619_invoices_rls.sql:4
RLS policy without user scoping
Scope the policy to the requesting user/role, e.g. USING (auth.uid() = user_id). If the data is intentionally public, make that explicit and document it.
status = 'active' is a genuine condition — it filters out archived rows. But it says nothing about who is asking. Evaluate it for any authenticated user (and, depending on your GRANTs, the anon role too) and every active invoice in the table matches. User A runs select * from invoices and gets User B's, User C's, and everyone else's active invoices. RLS is enabled. A policy exists. The data is still wide open horizontally across users.
The search that brings people here is usually some version of "supabase rls policy not filtering by user" — the rows come back, the filter clearly ran, and yet the result set contains other people's data. The condition was real; it just wasn't scoped to the requester.
The missing piece: auth.uid()
Inside an RLS policy, Supabase exposes the authenticated caller through three helpers:
auth.uid()— the user's UUID, read from the verified JWT. This is the one you want for row ownership.auth.jwt()— the full set of decoded JWT claims (useful for custom claims liketenant_id).auth.role()— the Postgres role the request runs as (anon,authenticated, ...).
If your predicate references none of these, the policy cannot distinguish one user from another. It is structurally incapable of per-user scoping. That is the exact heuristic GuardLayer's policy-no-user-scope rule applies: a CREATE POLICY that has a USING/WITH CHECK predicate, isn't the literal (true) case, and mentions none of auth.uid(), auth.jwt(), or auth.role().
The fix
Bind the predicate to the caller. The table needs a column that records ownership — typically user_id uuid references auth.users (id) — and the policy compares it to auth.uid():
alter table public.invoices enable row level security;
create policy "Users read their own invoices"
on public.invoices
for select
using (auth.uid() = user_id);
Now the expression is evaluated per caller: User A only matches rows where user_id equals A's id. Keep your business condition too — just AND it onto the ownership check:
create policy "Users read their own active invoices"
on public.invoices
for select
using (auth.uid() = user_id and status = 'active');
A few details that bite people:
- INSERT and UPDATE need
WITH CHECK, notUSING.USINGfilters which existing rows are visible or affected;WITH CHECKvalidates the new or modified row. To stop a user from creating a row owned by someone else:with check (auth.uid() = user_id). (An UPDATE policy generally wants both —USINGto pick the rows andWITH CHECKto validate the result.) - Multi-tenant? Scope by tenant via a claim. If ownership is at the org level, pull the tenant from the JWT:
using ((auth.jwt() ->> 'tenant_id')::uuid = tenant_id). That still referencesauth.jwt(), so it's genuinely caller-scoped — and the claim must be one your auth hook actually sets. - Write one policy per command (
for select,for insert,for update,for delete) so each gets the rightUSING/WITH CHECKcombination. A singlefor allpolicy that only setsUSINGleaves inserts unchecked. - Index the ownership column.
auth.uid() = user_idis evaluated against rows on every query; a btree index onuser_idkeeps it fast. Wrapping it as(select auth.uid()) = user_idalso lets the planner evaluateauth.uid()once per query instead of per row.
A 30-second self-check
Run this against your project to find policies whose body never mentions the caller:
select schemaname, tablename, policyname, cmd, qual, with_check
from pg_policies
where schemaname = 'public'
and coalesce(qual, '') not like '%auth.%'
and coalesce(with_check, '') not like '%auth.%';
Anything that comes back has a predicate (or none) that never references auth.* — i.e. it can't be scoping by user. Eyeball each one: is it intentionally public, read-only reference data, or is it a table with per-user rows that's quietly leaking? Or scan the migration files directly before they ever reach the database:
# CREATE POLICY blocks in your migrations that never mention auth.
grep -rizP "create policy[\s\S]{0,600}?;" supabase/migrations/ \
| grep -ivP "auth\.(uid|jwt|role)\("
GuardLayer flags this on every push — it parses each CREATE POLICY statement, checks for an auth.uid()/auth.jwt()/auth.role() reference, and reports the ones missing it inline, alongside the rest of its Supabase and Next.js ruleset.
FAQ
Isn't status = 'active' doing real filtering?
Yes — but only on a row attribute, not on identity. It removes archived rows for everyone equally. It never narrows the result to the rows that belong to the caller, so cross-user access stays open.
My policy joins another table to check membership. Is that scoped?
Only if the join ultimately ties back to auth.uid() (or a JWT claim). using (exists (select 1 from memberships m where m.invoice_id = invoices.id and m.user_id = auth.uid())) is scoped. The same subquery without auth.uid() is not — it just asks "does a membership exist," which is true for many users.
Is this the same as the USING (true) problem?
Same outcome — over-broad access — but a different shape. USING (true) is unconditional. This one has a condition that simply isn't about the user, which makes it far easier to miss in review.
Could the anon role hit this policy too?
If anon has been granted access to the table, yes. Policies apply to whatever role the request runs as, and a predicate with no auth.* reference doesn't even need a logged-in user to match.
What about public, read-only reference data (e.g. a countries table)?
That's a legitimate use of a non-user-scoped policy. Make the intent explicit — name the policy clearly and keep it for select only — so it reads as deliberate rather than an oversight.
Catch this before it ships — free
GuardLayer scans every push for this and 19 other Next.js + Supabase issues, with the exact fix inline.