Skip to content

Build1 publisher3 min readPublished

Moving tenant isolation into Postgres policies buys zero rows instead of a cross-tenant leak

Postgres policies do not apply to superusers or table owners, so one Next.js team's isolation pattern turns on which role the app connects as and on a variable set inside each request's transaction.

The Engineer · Build desk

Illustration accompanying Moving tenant isolation into Postgres policies buys zero rows instead of a cross-tenant leak

What happened

  • Their policy reads current_setting('app.organization_id', true), so an unset variable yields NULL, Postgres excludes the row, and a query missing tenant context sees an empty table.
  • They now run two roles with separate pools, a superuser admin pool capped at 2 connections and an app_user tenant pool capped at 4, each tagged with its own application_name.
  • The post says plain SET app.organization_id is session-scoped and wrong in a way that only appears under load, so the setup uses SET LOCAL to scope the value per request.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

  • constraint Adopting this splits roles you probably share today: the role that owns the tenant tables is exempt from its own policies unless the tables are forced, so migrations and runtime cannot use the same login.
  • decision Every environment now needs a second connection string, and a test suite that passes against the default postgres URL is not exercising the policies.
  • exposure A half-finished rollout with USING clauses only locks down reads while leaving inserts and updates free to write another tenant's organization_id.
  • cost Keeping the ergonomics of a plain import means paying for a getter and a Proxy on every call site. That cost buys a build that no longer requires a running database.

The whole pattern rests on one optional argument. `current_setting('app.organization_id', true)` returns NULL instead of raising an error when the setting is missing [13]. The policy compares `organization_id` to that NULL, gets NULL, and Postgres treats a NULL policy result the same way it treats false: exclude the row [13]. A handler that never set tenant context therefore selects from what looks like an empty table [13]. "That's the property I actually care about here," the post's author wrote [14].

That property exists only if policies apply to the connection at all. Postgres exempts superusers and roles holding BYPASSRLS, and the post quotes the docs as saying policies simply do not apply [2]. Table owners get the same exemption unless the table is switched to FORCE ROW LEVEL SECURITY [2]. Most local installs hand you `postgres` [3]. On the team's first attempt, ENABLE ROW LEVEL SECURITY had run, the policies existed, and every query still returned every tenant's rows [1].

So they run two roles, each with its own pool: `DATABASE_URL` as the superuser, capped at 2 connections and tagged `application_name` `app-admin`, and `APP_DATABASE_URL` as `app_user`, capped at 4 and tagged `app-tenant` [7]. Six connections per process at full stretch [8]. The tags cost nothing and mean `pg_stat_activity` names the pool that is starved instead of leaving you to guess [11].

Lazy construction of those pools is a Next.js constraint. Module scope runs during the build, so a pool opened at import time makes `next build` require a live database, which is how the team hit it in CI [9]. A getter defers the connection to the first query, and a `Proxy` keeps `import db` behaving like an ordinary object [10]. "I won't pretend this is elegant," the author wrote of that wrapper [10].

Setting the variable per request is the thinnest part of the write-up. Plain `SET app.organization_id = '...'` is session-scoped, and the post says it is wrong in a way that will not show up until you are under load [16]. Session scope plus four pooled connections reused across requests means the value outlives the request that set it, and the next request holding that connection inherits the previous tenant [17]. What the team landed on is `SET LOCAL` [6].

For zero rows to be the failure mode in another codebase, four things have to hold together: the runtime role is neither a superuser nor a BYPASSRLS holder, it does not own the tenant tables unless those tables are forced [2], every table carries a `WITH CHECK` clause as well as `USING` because `USING` governs reads only [15], and the variable is set inside the same transaction as the query [16]. The team caught the missing `WITH CHECK` while reviewing one migration [15].

What to watch

  • Whether the team publishes the request wrapper that opens a transaction per request, since SET LOCAL only helps inside one.
  • Whether migrations move to a role that does not own the runtime tables, or the tables get FORCE ROW LEVEL SECURITY instead.
  • A published measurement of per-request overhead under load, which would test the claim that plain SET fails only when traffic arrives.
Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories