DEV Community

Cover image for PgBouncer Transaction Mode Leaks Session State Between Tenants
soy
soy

Posted on Originally published at media.patentllm.org

PgBouncer Transaction Mode Leaks Session State Between Tenants

A blog post from Seedfast, tested against PostgreSQL 18.6 and PgBouncer 1.25.2, shows that PgBouncer's transaction pooling mode hands a backend connection to a new client without clearing custom session parameters such as app.tenant, letting one client inherit another's row-level-security context. The failure is acute for AI agent workloads: since the MCP specification dropped session state from the protocol on 28 July 2026, each tool call now opens with a bare SET app.tenant outside any transaction, exactly the pattern the pooler mishandles. It is worth reading because the failure mode is silent — RLS policies keep evaluating, just against the wrong tenant.

What changed

PgBouncer's transaction pooling mode returns a backend connection to the pool as soon as a transaction (or, for autocommit statements, a single statement) finishes, and reuses that same backend for the next client that needs one. At handover, PgBouncer only resets a fixed set of tracked parameters — client_encoding, datestyle, timezone, standard_conforming_strings, application_name — plus whatever the configured server_reset_query covers. Anything set with a plain SET on a custom GUC, such as the common app.tenant variable read by a row-level-security policy via current_setting('app.tenant', true), is left in place on the backend and is inherited verbatim by the next client PgBouncer assigns to it.

The article demonstrates this directly: two client connections opened one after another through PgBouncer in transaction mode, each querying which rows they were allowed to see, and the second connection got the first connection's answer, because the backend still carried the first tenant's app.tenant setting.

The same handover carries other state readers may not expect: SET ROLE (and therefore current_user), search_path, statement_timeout, temp tables created without ON COMMIT DROP, PREPAREd statements, DECLARE ... CURSOR WITH HOLD, LISTEN subscriptions, and session-level advisory locks taken with pg_advisory_lock(). Only transaction-scoped variants — SET LOCAL, set_config(..., is_local=true), SET LOCAL ROLE, pg_advisory_xact_lock() — are discarded at COMMIT and therefore safe under transaction pooling.

A second, subtler effect: once a custom parameter has been touched at all on a backend, current_setting(name, true) returns an empty string instead of NULL for the remaining life of that backend, silently breaking IS NULL checks and COALESCE fallbacks that assume an unset variable.

The root cause behind the current spike in exposure is architectural: the MCP specification removed session state from the protocol on 28 July 2026, so each agent tool call now arrives as an independent request that opens with its own SET app.tenant outside any transaction — precisely the shape PgBouncer's transaction mode cannot safely handle.

Who this affects

Anyone running PostgreSQL behind PgBouncer (or a similarly behaved pooler) in transaction pooling mode, with multi-tenant row-level security keyed on session-level current_setting() values, is exposed. The article's proof of concept used PostgreSQL 18.6 and PgBouncer 1.25.2, but the mechanism is a documented property of transaction pooling itself, not a version-specific bug. It is especially relevant to teams exposing Postgres to AI agents through MCP tool calls, since the 28 July 2026 protocol change that removed session state encourages exactly the bare SET-then-query pattern that leaks. Teams using session pooling mode or dedicated per-tenant connections are not affected, since PgBouncer only skips the reset behavior in transaction mode. Setups that already wrap every request in an explicit transaction and use SET LOCAL for tenant context are also safe, as are those relying solely on application-level tenant filtering rather than session-variable-driven RLS.

Verdict

This is not a wait-and-see item: any multi-tenant deployment using PgBouncer transaction mode with session-variable-based RLS should audit its connection code today, specifically searching for bare SET app.tenant (or equivalent) calls that are not wrapped in BEGIN ... SET LOCAL ... COMMIT. The fix requires no PgBouncer upgrade or config flag — it is an application-side discipline change: issue an explicit transaction per request, set tenant context with SET LOCAL, and let COMMIT clear it, plus swapping session-level advisory locks and LISTEN usage for transaction-safe equivalents where pooled. Teams building or operating MCP-based agent tool servers against pooled Postgres should treat this as urgent, since the protocol's removal of session state pushes exactly the vulnerable pattern. Do not reach for server_reset_query_always as a blanket fix — it forces a DISCARD ALL after every transaction, breaking legitimate multi-statement workflows.

Tracked daily from official release feeds and vendor changelogs. Full archive: https://media.patentllm.org

Top comments (0)