DEV Community

QAYS KADHIM
QAYS KADHIM

Posted on

The `SET LOCAL` advice is right, and it understates the problem

Everyone tells you to use SET LOCAL for tenant context in Postgres RLS. The usual reason given understates the blast radius — through PgBouncer in transaction pooling mode, a session-scoped setting survives the client connection, not just the transaction.

Every guide to row-level security in Postgres tells you the same thing: set your tenant context with SET LOCAL, not SET.

The reason given is usually this — a session-scoped setting survives the transaction, so the next request on that connection inherits it. Your policies keep evaluating perfectly, against the previous tenant's workspace. Nothing errors. You just quietly serve one customer another customer's data.

That's true. It's also not the whole failure.

I migrated a multi-tenant SaaS to Postgres RLS recently. The app runs serverless behind a pooled connection — PgBouncer in transaction pooling mode, which is what most managed Postgres providers give you by default. I wrote a test that deliberately leaks, so the difference between SET and SET LOCAL would be a fact my suite established rather than a claim in a comment.

Then an unrelated test, further down the same file, failed:

AssertionError: a pooled connection retained a workspace context:
  expected 'd0c4d7b6-54c4-4f86-a4ee-9296798aedef' to be ''
Enter fullscreen mode Exit fullscreen mode

That test opened its own fresh client connections. It found a workspace id set by a connection that had already been closed.

PgBouncer doesn't clean up between clients

In transaction pooling mode, PgBouncer does not issue DISCARD ALL when a client disconnects. The server connection goes back into the pool carrying whatever session state was left on it, and gets handed to whoever asks next.

So the blast radius isn't "the next request on this connection, in this process."

It's: one set_config(..., false) anywhere poisons a shared server connection for an unbounded number of future requests, from any process, until something happens to overwrite it. Different request, different process, different tenant, possibly minutes later.

Serverless makes this worse rather than better. Many short-lived processes share a small pool of long-lived server connections, so the polluted connection outlives every process that could have cleaned it up.

Reproducing it

// 1. Poison a server connection, then disconnect entirely.
const a = new Client({ connectionString: POOLED_URL });
await a.connect();
await a.query(`SELECT set_config('app.leak_probe', 'sticky', false)`);
await a.end();

// 2. Open fresh clients until one is handed the same server connection.
for (let i = 0; i < 12; i++) {
  const b = new Client({ connectionString: POOLED_URL });
  await b.connect();
  const { rows } = await b.query(`SELECT current_setting('app.leak_probe', true) AS v`);
  if (rows[0].v === 'sticky') console.log('leaked to a new client connection at attempt', i);
  await b.end();
}
Enter fullscreen mode Exit fullscreen mode

The loop is needed because which server connection you get isn't deterministic. With a default pool size of a few connections it typically hits within a handful of attempts.

What follows from it

set_config(..., true) is not a style preference. It is the only thing standing between one mistake and an unbounded cross-tenant read.

A static check earns its place as the primary defence, not as belt-and-braces.

My build fails if set_config is called anywhere in application code with a false third argument, and if anything outside two designated modules sets the tenant context at all. That check is doing more work than any runtime guard could, because the failure it prevents is silent, delayed, and attributable to no particular request. There's nothing to alert on. Nobody files a bug.

It generalises past tenant context.

Any session-level state on a pooled connection has this property — SET search_path, SET timezone, advisory locks, prepared statements. Use SET LOCAL, or use the direct endpoint.

And the leak test stays in the suite

, asserting that the leak does occur. If it ever starts failing, the pooler has begun resetting session state — which is a change worth learning about deliberately rather than discovering later.

Top comments (0)