A pre-request function is PostgREST middleware: keys, tenants, 403s

pgrst.db_pre_request names a function that runs before every Data API request with headers and path in scope; raise SQLSTATE PGRST to send your own status.

Data API

· Chapter

35

·

3

min read

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 through fetch() against /rest/v1/rpc/… with apikey plus your headers.
  • The function runs as the request's role unless it is security definer; make it definer, pin search_path, and read keys from a private schema.
  • notify pgrst, 'reload config' is required after alter 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