How a Missing server_reset_query Let PgBouncer Leak One Tenant's search_path Into Another's Queries for 23 Minutes
← Back
October 8, 2026Database8 min read

How a Missing server_reset_query Let PgBouncer Leak One Tenant's search_path Into Another's Queries for 23 Minutes

Published October 8, 20268 min read

03:41 UTC. A support ticket lands with a screenshot attached: a Meridian Logistics admin looking at an invoice for $84,213 that isn't theirs. The billing contact name on it belongs to a completely different customer, Acme Freight. Nobody on either account shares a user, a login, or a browser session. Two unrelated companies, one invoice, showing up in the wrong dashboard at 3am with no deploy in the last six hours to blame.


the scramble

First theory: a Redis cache key collision. The app caches per-tenant dashboard summaries, and a bad cache key has bitten this team before. The on-call engineer checks the key format for Meridian's dashboard cache entry, dash:tenant_8842:summary. Correctly namespaced, correct tenant ID, and the cached payload itself is clean. Not the cache.

Second theory: a JWT bug handing out the wrong tenant claim. Pulling the access token from the request that returned the bad invoice, the tenant_id claim reads 8842, Meridian's real ID, decoded and verified correctly by the auth middleware. The request asked for the right tenant. Whatever happened, happened after auth, somewhere between the app server and the database.

That's the pivot: this isn't an authorization bug, it's a bug in what the database believed it was supposed to be looking at.


the hunt

The schema-per-tenant design means every request sets its own search path before touching a table:

request middleware, tenant resolution
async def with_tenant_schema(conn, tenant_id: int):
    await conn.execute(f"SET search_path TO tenant_{tenant_id}, public")
    # unqualified table names now resolve inside tenant_{tenant_id}

Unqualified queries like SELECT * FROM invoices resolve against whatever schema is first on the search path, so this one line is the entire tenant boundary. If it's wrong, every query downstream is wrong too, silently, with no error to catch.

The app sits behind PgBouncer in transaction pooling mode, recycling a small pool of real Postgres backends across far more application connections than max_connections would otherwise allow. Pulling pg_stat_activity during a load test that reproduces the symptom shows the same backend PID serving Acme's request and then, forty milliseconds later, Meridian's:

pg_stat_activity, same backend pid across two requests
  pid  | query_start             | query
-------+--------------------------+----------------------------------------
 19042 | 03:41:02.114             | SET search_path TO tenant_8841, public
 19042 | 03:41:02.119             | SELECT * FROM invoices WHERE ...
 19042 | 03:41:02.161             | SELECT * FROM invoices WHERE ...

Only one SET search_path between two different tenants' SELECT statements. Meridian's request never set its own search path at all, and it didn't need to, because Acme's was still sitting on the connection from the previous transaction.

Checking recent deploys against the timestamp turns up a change from four days earlier, titled “fix: search_path resetting mid-batch in invoice reconciliation job.” The nightly reconciliation job runs several statements against a tenant's schema without wrapping them in one transaction, and someone had used SET LOCAL search_path, which resets at the end of its own transaction rather than staying for the whole job, breaking the batch partway through. The fix swapped it for a plain SET search_path, which stays set for the life of the session instead of the transaction. The reconciliation job worked perfectly after that. Nobody connected it to request-serving connections, because the job and the request path share the same connection pool and the same PgBouncer tier.


the find

PgBouncer's own documentation calls this out directly: in transaction pooling mode, a connection is handed back to the pool as soon as a transaction commits, and anything reset by COMMIT or ROLLBACK, like SET LOCAL values, goes with it. A plain SET, issued outside SET LOCAL, is session state, not transaction state, and COMMIT doesn't touch it. The only thing that clears it is server_reset_query, and by default PgBouncer only runs that query automatically in session pooling mode. In transaction pooling, server_reset_query_always defaults to 0, so the reset query never ran:

pgbouncer.ini, as found
pool_mode = transaction
server_reset_query = discard all
server_reset_query_always = 0   ; default: reset only runs in session pooling mode

Every tenant's request had been quietly assuming the backend connection it got handed was a blank slate. That assumption held for as long as every SET statement anywhere in the codebase stayed scoped with LOCAL. One background job, patched for an unrelated reason by someone who had no idea PgBouncer worked this way, broke that assumption for every request sharing its connection pool.


the fix

Immediate containment: force the reconciliation job onto its own dedicated PgBouncer pool, isolated from the request-serving pool, so its session-scoped SET can never bleed into a customer-facing connection:

pgbouncer.ini, pool split
[databases]
app_requests = host=pg-primary dbname=app pool_mode=transaction
app_batch    = host=pg-primary dbname=app pool_mode=transaction

The real fix was two layers, not one. First, server_reset_query_always = 1, so DISCARD ALL runs on every connection release regardless of pooling mode, at the cost of a small amount of latency per transaction, accepted deliberately as the price of a tenant boundary that can't silently fail open:

pgbouncer.ini, after
server_reset_query = discard all
server_reset_query_always = 1   ; always reset, even in transaction pooling

Second, the request middleware stopped trusting that a freshly checked-out connection has no tenant context at all, and started asserting it instead:

request middleware, after
async def with_tenant_schema(conn, tenant_id: int):
    await conn.execute(f"SET search_path TO tenant_{tenant_id}, public")
    actual = await conn.fetchval("SELECT current_schema()")
    if actual != f"tenant_{tenant_id}":
        raise TenantIsolationError(expected=tenant_id, actual=actual)

The reconciliation job's original mid-batch problem got its own real fix too: it now opens one explicit transaction per batch and sets search path with SET LOCAL inside it, instead of relying on session-scoped state to survive between statements.


the aftermath

23 min From first ticket to pool isolation deployed
3 Tenant pairs confirmed to have shared a connection's search path
14 Cross-tenant reads found on audit replay, zero writes
0 PgBouncer config lines changed in the four days before the incident
  • SET LOCAL versus SET isn't a style choice in a connection-pooled system. It's the difference between state that dies with the transaction and state that outlives it, and outliving a transaction in transaction pooling mode means outliving the client that set it.
  • PgBouncer's default of not resetting session state in transaction pooling mode is documented, reasonable for stateless workloads, and a trap for anything using search_path, prepared statements, or session GUCs as part of an isolation boundary. Isolation boundaries deserve server_reset_query_always = 1, full stop.
  • A background job and a request-serving path sharing one PgBouncer pool means a fix to one can change the correctness guarantees of the other, with no shared code path and no shared deploy to connect them in a postmortem search.
  • Trusting that a freshly acquired connection has no leftover context is an assumption, not a guarantee. Asserting current_schema() against the expected tenant on every request turns a silent cross-tenant read into a loud, immediate exception instead.

The query was never wrong. It ran exactly the SELECT * FROM invoices it was told to run. The only thing missing was which invoices table that meant, and for twenty-three minutes, one connection's idea of the answer outlived the request that set it.

Share this
← All Posts8 min read