The answer. An RPC is a Postgres function in an exposed schema, reached as POST /rest/v1/rpc/<name> or supabase.rpc(). By default it runs as the caller (security invoker), so RLS applies and it can only ever return what that user could read anyway. The moment it needs more (the list of other members of my tenant, whose profile rows I cannot read), it becomes security definer, runs as its owner, and RLS no longer protects it. Three rules follow. Authorize inside the function using auth.uid(), which is still the caller's identity in a definer context, and raise exception otherwise. Pin search_path = '' and qualify every table, so a caller cannot smuggle in a same-named object. And revoke execute from public and anon, granting it to authenticated only. Predicates that policies share belong in a schema that is not exposed at all.
The pattern.
create or replace function public.tenant_members(tenant_id text) returns jsonb
language plpgsql stable security definer set search_path = '' as $$
begin
if not exists (select 1 from public.memberships m join public.profiles p on p.id = m.profile_id
where p.user_id = (select auth.uid()) and m.tenant = tenant_id) then
raise exception 'no access to tenant %', tenant_id using errcode = '42501';
end if;
return (select coalesce(jsonb_agg(jsonb_build_object('id', p.id, 'full_name', p.full_name, 'available', p.is_available)), '[]')
from public.profiles p join public.memberships m on m.profile_id = p.id where m.tenant = tenant_id);
end $$;
revoke execute on function public.tenant_members(text) from public, anon;
grant execute on function public.tenant_members(text) to authenticated;
Watch out.
current_userinside a definer chain is the owner (postgres), not the caller. Authorize withauth.uid()orauth.role(), never withcurrent_user.- In the SQL editor you are
postgres,auth.uid()is null, and the guard fires; test by impersonating a user in Studio or withset local rolein a transaction. - The advisor lints definer functions executable by
anonorauthenticated(0028, 0029). Every hit needs either a guard inside or a revoke outside.
Related: a-policy-that-reads-another-table-runs-that-tables-policies · pin-search-path-and-keep-extensions-out-of-public · rls-test-set-local-role-and-jwt-claims