The answer. PostgREST can run one function of yours inside the transaction of every request, before the query it was asked for. That function sees the request: current_setting('request.headers', true) as JSON, request.path, request.method, request.jwt.claims. It can therefore validate a per-tenant API key sent in a header, reject requests to a path from an unknown caller, or count requests per IP for a crude rate limit. Rejecting is done by raising an error with SQLSTATE PGRST, whose message and detail PostgREST turns into an HTTP status, headers and JSON body of your choosing. Keep it cheap and path-scoped: it runs for every table read and every RPC, and a slow lookup here is a slow lookup on every request. Store the keys it compares against in Vault, not in a plaintext table.
The pattern.
create or replace function public.api_gate() returns void language plpgsql security definer set search_path = '' as $$
declare h json := current_setting('request.headers', true)::json; p text := current_setting('request.path', true);
begin
if p not like '/rpc/partner_%' then return; end if; -- only the partner surface
if not exists (select 1 from internal.api_keys k where k.key = h->>'x-api-key' and k.tenant = h->>'x-tenant') then
raise sqlstate 'PGRST' using
message = json_build_object('code', 'AUTH-01', 'message', 'invalid api key', 'details', null, 'hint', null)::text,
detail = json_build_object('status', 403, 'headers', json_build_object('x-reason', 'api-key'))::text;
end if;
end $$;
alter role authenticator set pgrst.db_pre_request = 'public.api_gate';
notify pgrst, 'reload config';
client IP inside the DB: split_part(current_setting('request.headers', true)::json->>'x-forwarded-for', ',', 1)
Watch out.
supabase.rpc()cannot set custom headers; partner calls go throughfetch()against/rest/v1/rpc/…withapikeyplus your headers.- The function runs as the request's role unless it is
security definer; make it definer, pinsearch_path, and read keys from a private schema. notify pgrst, 'reload config'is required afteralter role; forgetting it is the classic "my middleware does nothing" ticket.
Related: expose-only-an-api-schema-to-postgrest · vault-keeps-third-party-secrets-inside-postgres · postgrest-max-rows-and-range-pagination