October 13, 2026

The Hidden Multi-Tenant Danger in PostgreSQL and Go: A Deep Dive into Row-Level Security Leaks

the-hidden-multi-tenant-danger-in-postgresql-and-go-a-deep-dive-into-row-level-security-leaks

the-hidden-multi-tenant-danger-in-postgresql-and-go-a-deep-dive-into-row-level-security-leaks

By Tech Insights Desk
Published: October 2026

Multi-tenant SaaS architectures built on PostgreSQL frequently leverage Row-Level Security (RLS) to enforce strict data isolation between customers. While RLS provides a robust database-level security boundary, properly propagating the current tenant context from an application layer written in Go—specifically using the popular pgx driver—is fraught with subtle, dangerous edge cases.

A comprehensive empirical study conducted in October 2026 across nine different implementation strategies using Go 1.25, pgx v5.11, PostgreSQL 17, and PgBouncer has revealed alarming failure modes. Under concurrent workloads, improper connection management leads to severe data leaks, where customer requests routinely access, count, or modify invoices belonging to completely different tenants.


Main Facts: The Core Vulnerability

At the heart of the issue is how PostgreSQL associates tenant context with a connection. Because RLS relies on session or transaction-local settings—typically invoked via set_config('app.tenant', 'tenant_id', is_local)—the application must ensure that the statement setting the tenant and the subsequent queries execute on the exact same connection, within the correct scope, without leaking state to subsequent requests.

In Go, connection poolers like pgxpool dynamically assign and recycle database connections from a shared pool for individual operations (pool.Exec, pool.QueryRow). When developers execute set_config as an independent statement followed by a query, Go treats these as two distinct operations. Under concurrent loads, they frequently land on entirely different physical database connections.

Key Takeaways from the Study:

  • The Leakage Rate: Under concurrent traffic using naive pool.Exec patterns, roughly 50% of tenant requests were exposed to data from other customers.
  • The PgBouncer Catastrophe: When routing traffic through PgBouncer in transaction mode, pool-level connection hooks (PrepareConn and AfterRelease) completely break down, resulting in corrupted states, missing rows, and widespread cross-tenant data exposure.
  • The Gold Standard: The only infallible methods for maintaining data isolation are wrapping operations in an explicit transaction (pgx.BeginFunc / db.BeginTx) that initializes the tenant as its first statement, or utilizing transactional batching (pgx.SendBatch) executed in a single round-trip.

Chronology of the Discovery and Testing Methodology

The testing environment was rigorously standardized to isolate connection behavior under load.

  • The Setup: The experiments utilized Go 1.25.14, pgx v5.11.0, and PostgreSQL 17.11 running within an isolated container environment. For pooler evaluations, PgBouncer 1.26.0 was configured in transaction mode with a tight default_pool_size of 5 to force high connection contention.
  • Phase 1: Sequential Testing: Initially, requests were executed sequentially (Tenant 1, No Tenant, Tenant 2, No Tenant). Under single-threaded execution, most variants appeared to function correctly, hiding underlying structural flaws because sequential requests happened to grab the same reused connection.
  • Phase 2: Concurrent Load Testing: Researchers scaled the test to 600 simultaneous requests (30 in flight concurrently, distributed evenly across two tenants and unauthenticated requests). This is where the architecture of pgxpool and connection multiplexing exposed catastrophic data leakage.

Supporting Data: Comparative Analysis of Tenant Configuration Strategies

The empirical data gathered across 1,200 test runs highlights the stark contrast between flawed implementation patterns and secure architectures.

Direct Connection & Pool Hook Performance (PostgreSQL Direct)

How the Tenant is Set Tenant Requests Seeing Another Customer’s Data Unauthenticated Requests Seeing Rows
pool.Exec (is_local=false), then pool.QueryRow ~46% to 48% (193/178 out of 400) 98% to 100%
database/sql (db.Exec), then db.QueryRow ~48% to 49% (199/193 out of 400) 98% to 100%
pool.Acquire, set and query, then Release 0 out of 400 98% to 100% (leaks to subsequent queries)
PrepareConn hook sets, no reset 0 out of 400 99% to 100%
PrepareConn sets, AfterRelease resets 0 out of 400 0 out of 200
pgx.BeginFunc / db.BeginTx (is_local=true) 0 out of 400 0 out of 200
pool.SendBatch (is_local=true first) 0 out of 400 0 out of 200

Performance Behind PgBouncer (Transaction Mode)

When introducing PgBouncer in transaction mode, session-level assumptions shattered entirely. Because PgBouncer multiplexes client connections across a smaller pool of server connections on a per-transaction basis, hook-based strategies (PrepareConn and AfterRelease) became entirely decoupled from the actual queries being executed.

PgBouncer Configuration Strategy Requests with Own Rows Another Customer’s Rows No Rows Returned Unauthenticated Seeing Rows
PrepareConn sets, AfterRelease resets ~24% ~18% ~55% ~38%
PrepareConn sets, no reset ~37% ~30% ~30% ~59%
pool.Acquire, set and query ~53% ~45% ~1% 100%
pgx.BeginFunc / db.BeginTx (is_local=true) 400 out of 400 0 0 0 out of 200
pool.SendBatch (is_local=true first) 400 out of 400 0 0 0 out of 200

Technical Deep Dive: Why Exec and QueryRow Fail

The Connection Pooling Disconnect

When developers write code using pool.Exec to set a configuration variable followed by pool.QueryRow to fetch data, they assume both statements share a continuous session. In Go’s pgxpool, however, each individual method call acquires a connection from the pool, executes its single statement, and immediately checks the connection back in.

Under low traffic, the pool happens to hand back the same underlying connection. Under concurrent load, pool.Exec sets the tenant on Connection A, but pool.QueryRow acquires Connection B. Consequently, the query runs without a tenant context—or worse, inherits a stale tenant context left behind by an entirely different user.

The Illusion of Connection Hooks

To combat this, developers often turn to pgxpool.Config hooks like PrepareConn (which replaces the deprecated BeforeAcquire) and AfterRelease. While these hooks successfully initialize tenant settings when connecting directly to PostgreSQL, they introduce severe architectural limitations:

  1. Context Mismatch: The hook reads the context of the connection acquisition, not the individual statement or request handler context. If a connection is held or reused, subsequent operations retain the initial tenant context indefinitely.
  2. Session-Level Contamination: Because these hooks operate at the connection lifecycle level rather than the transaction level, unauthenticated requests routinely pick up dirty connections bearing active tenant configurations.
  3. PgBouncer Incompatibility: In PgBouncer transaction mode, the hook’s execution (set_config), the application query, and the cleanup hook (reset) are treated as three completely independent transactions executed across potentially distinct backend database processes. Tracing data packets reveals that queries frequently land on backends where settings were either empty, wiped by another user’s reset, or contaminated by a completely different tenant.

Verified Solutions: What Actually Works

Engineering teams building multi-tenant Go applications must adopt strict patterns to guarantee data isolation under high concurrency.

1. Transaction-Scoped Configuration (BeginFunc)

The most reliable pattern is encapsulating the tenant initialization and all related queries inside a single, explicitly managed transaction using pgx.BeginFunc or standard database transactions (database/sql via BeginTx). By passing is_local = true to set_config, the configuration variable automatically expires the moment the transaction commits or rolls back.

func withTenant(ctx context.Context, pool *pgxpool.Pool, tenant string,
    fn func(tx pgx.Tx) error) error 
    return pgx.BeginFunc(ctx, pool, func(tx pgx.Tx) error 
        // is_local = true ensures the setting dies with the transaction
        if _, err := tx.Exec(ctx,
            "select set_config('app.tenant', $1, true)", tenant); err != nil 
            return err
        
        return fn(tx)
    )

2. Transactional Batching for Single Queries

For single-query endpoints, utilizing pgx.SendBatch ensures that the configuration command and the query are transmitted to the database in a single round-trip, executing atomically within a transient transaction block.

b := &pgx.Batch
b.Queue("select set_config('app.tenant', $1, true)", tenant)
b.Queue("select count(*) from invoices")
br := pool.SendBatch(ctx, b)
defer br.Close() 

if _, err := br.Exec(); err != nil 
    return 0, err

var n int
if err := br.QueryRow().Scan(&n); err != nil 
    return 0, err

return n, br.Close()

Critical Rules for Developers:

  • Always Pass the Tenant Explicitly: Even for unauthenticated or public routes, explicitly invoke set_config('app.tenant', '', true). Omitting this leaves the connection vulnerable to reading stale session data if a previous execution leaked a session-level configuration.
  • Route All Queries Through the Transaction Handle (tx): Issuing queries against the general pool inside a transaction block bypasses the transaction entirely, resulting in unisolated data access or connection deadlocks under heavy load.
  • Format Types Correctly: PostgreSQL’s set_config expects a text string. Passing raw Go integers or non-string primitives directly into the query driver will throw encoding errors (unable to encode into text format).

Implications for SaaS Architecture

The findings from this 2026 study carry profound implications for Go developers and enterprise architects relying on PostgreSQL Row-Level Security:

  1. Avoid Naive Connection Reuse: Relying on sequential method calls (pool.Exec followed by pool.QueryRow) in multi-tenant systems is fundamentally flawed and introduces severe security vulnerabilities.
  2. Re-evaluate Connection Poolers: While PgBouncer in transaction mode is a staple for scaling PostgreSQL connections, it is fundamentally incompatible with session-level context setting hooks. Teams utilizing PgBouncer must enforce transaction-scoped configurations (is_local = true).
  3. Audit Existing Codebases: Development teams must audit their data access layers to ensure that connection state is never assumed, hooks are not misused for multi-tenant isolation, and every query requiring isolation is strictly bound to a transaction or atomic batch.

By shifting from session-bound assumptions to strictly transactional context propagation, Go developers can harness the full power of PostgreSQL Row-Level Security without exposing their applications to catastrophic data breaches.