Row-level security policy examples for the cases that are not "owns the row"
A policy that checks auth.uid() against a user_id column is the easy case. Multi-tenant teams, Storage buckets, admin bypass and Realtime subscriptions each need a slightly different pattern, and getting the performance detail wrong turns RLS into the slowest part of the query.
Last checked October 12, 2026.
Multi-tenant: a team can see its own rows, nothing else
Put the membership check behind a security-definer SQL function in a private schema the client cannot call directly. The function runs with the privileges of its owner, so it reads the membership table without RLS recursing back into the policy that is calling it.
create schema if not exists private;
create function private.user_org_ids()
returns setof uuid
language sql
security definer
set search_path = ''
stable
as $$
select org_id from public.org_members
where user_id = (select auth.uid())
$$;
grant usage on schema private to authenticated;
grant execute on function private.user_org_ids() to authenticated;
create policy "members read their organization's rows"
on public.documents for select
to authenticated
using ( org_id in (select private.user_org_ids()) );Sources: Supabase: Row Level Security
Wrap auth.uid() in select, or it runs once per row
Postgres caches the result of a wrapped function call for the statement (an initPlan) instead of evaluating it on every row the policy filters. On a table with more than a few thousand rows the difference is not subtle.
- Index every column a policy filters on. A column only counts as indexed when it is the leading column of a btree index, so a composite primary key (team_id, user_id) still needs its own index on user_id alone if a policy filters by user_id.
-- slow: auth.uid() evaluated once per row
create policy "owns the row" on profiles for select
to authenticated using ( auth.uid() = user_id );
-- fast: cached once per statement
create policy "owns the row" on profiles for select
to authenticated using ( (select auth.uid()) = user_id );Sources: Supabase: Row Level Security
Storage: policies live on storage.objects, not the bucket
Storage has no separate policy UI concept of its own; every upload, download, list and delete is a row operation on storage.objects, so a Storage policy is a normal RLS policy that checks bucket_id and the file path.
create policy "users upload to their own folder"
on storage.objects for insert
to authenticated
with check (
bucket_id = 'avatars'
and (storage.foldername(name))[1] = (select auth.uid())::text
);Sources: Supabase: Storage access control
Realtime Postgres Changes: same policies, one gap on delete
A Postgres Changes subscription authorizes every event against the subscriber's own RLS, server-side, so a SELECT policy that already works for queries also filters realtime payloads. Two steps are easy to miss: the table has to be added to the supabase_realtime publication, and RLS cannot filter DELETE events (there is no deleted row left to check a policy against) unless the table's replica identity is set to full.
alter publication supabase_realtime add table public.messages;
alter table public.messages replica identity full; -- needed to filter delete eventsSources: Supabase: Postgres Changes
Admin or service bypass without disabling RLS
The service_role key bypasses RLS entirely, which is correct for trusted server code but too wide for an in-app admin role. Give admins their own permissive policy instead of turning RLS off for the table.
create policy "admins read every row"
on public.documents for select
to authenticated
using ( (select auth.jwt() -> 'app_metadata' ->> 'role') = 'admin' );Sources: Supabase: Row Level Security
RLS on Scribase
Scribase runs plain Postgres with the same auth.uid() and storage.objects conventions, so every policy on this page works unchanged after a move. A project is $25 a month and the importer carries RLS policies over with the schema.
Sources: Scribase pricing
Questions people ask
Why does my RLS policy get slower as the table grows?
If a function call like auth.uid() is not wrapped in select, Postgres evaluates it per row instead of once per statement. Wrap it, and index every column the policy filters on.
Why do I get infinite recursion on my RLS policy?
It happens when a policy on table A queries table B, and table B's own policy queries table A back. Move the lookup into a security-definer function in a private schema; the function bypasses RLS on the table it reads, breaking the loop.
Does RLS apply to Realtime subscriptions?
Yes, Postgres Changes checks each subscriber's RLS server-side before sending an event. The table also needs to be added to the supabase_realtime publication, and DELETE events are only visible with replica identity full.
Can I write a Storage policy for a specific folder?
Yes. storage.objects rows include the full object path; storage.foldername(name) splits it into an array so a policy can match the first segment against the user's id.