A SECURITY DEFINER RPC is a controlled hole in RLS: guard it, pin it

Any non-trigger function in an exposed schema is callable with the publishable key. Definer functions must check auth.uid(), pin search_path, revoke public.

Schema & Postgres

· Chapter

27

·

3

min read

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_user inside a definer chain is the owner (postgres), not the caller. Authorize with auth.uid() or auth.role(), never with current_user.
  • In the SQL editor you are postgres, auth.uid() is null, and the guard fires; test by impersonating a user in Studio or with set local role in a transaction.
  • The advisor lints definer functions executable by anon or authenticated (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