Wrappers turn Stripe or S3 into foreign tables; keep them private

A foreign data wrapper proxies an external API as tables you can join. They carry no RLS: keep them in a private schema behind a guarded function, key in Vault.

Extensions & Cron

· Chapter

44

·

2

min read

The answer. Postgres foreign data wrappers let a table be backed by something that is not this database, and Supabase ships wrappers for Stripe, S3, BigQuery, Firebase, another Postgres and more. After enabling wrappers and creating a server with the credential, stripe.customers is a table: select reaches out to the API on demand, joins against your own tables work, and a new customer in Stripe appears on the next query. Two properties decide how you use it. Foreign tables have no row-level security, so exposing one through the Data API hands every API caller the whole dataset; they belong in a schema that is not exposed, read through a security definer function that filters. And the credential is the provider's secret key, which is why it is referenced from Vault rather than pasted into the server options.

The pattern.

create extension if not exists wrappers with schema extensions;
create foreign data wrapper stripe_wrapper handler stripe_fdw_handler validator stripe_fdw_validator;
select vault.create_secret('sk_live_…', 'stripe');
create server stripe_server foreign data wrapper stripe_wrapper options (api_key_id (select id::text from vault.secrets where name = 'stripe'));
create schema stripe;                                            -- not in "Exposed schemas"
import foreign schema stripe limit to ("customers", "subscriptions") from server stripe_server into stripe;

create or replace function public.my_subscription() returns table (status text, current_period_end timestamptz)
language sql stable security definer set search_path = '' as $$
  select s.attrs->>'status', to_timestamp((s.attrs->>'current_period_end')::bigint)
  from stripe.subscriptions s join public.billing_accounts b on b.stripe_customer = s.customer
  where b.user_id = (select auth.uid())
$$;
revoke execute on function public.my_subscription() from public, anon;

Watch out.

  • Every select is an API call with the provider's latency and rate limits; cache the results you list, never page a foreign table from a UI.
  • Some wrappers support only reads or only certain columns; attrs (JSON) holds what did not map cleanly.
  • The advisor flags foreign tables reachable through the API (0017); a hit there is a live data leak, not a style issue.

Related: vault-keeps-third-party-secrets-inside-postgres · expose-only-an-api-schema-to-postgrest · security-definer-rpc-is-a-controlled-hole-in-rls