Use SET LOCAL, or set_config with the third argument true, so the tenant setting lasts only for the current transaction. A plain SET persists until the session ends, and a pooled connection can carry that value into the next request, which may belong to a different tenant.
The failure in plain words
Many multi-tenant apps pass the current tenant to the database through a setting such as app.current_tenant, then let row-level security policies read it. That works well until connection pooling enters the picture.
A connection pool keeps database sessions open and lends them to requests. If request one runs a plain SET app.current_tenant = 'A' and returns the connection, the OWASP Multi-Tenant Security Cheat Sheet notes the value persists until the session ends. Request two for tenant B borrows the same session. If it forgets to set its own value, it runs as tenant A.
That is a cross-tenant data leak caused by a lifecycle detail, not by a wrong query.
What the database documentation guarantees
Per the cheat sheet, a normal SET survives after its transaction commits, while SET LOCAL lasts only for the current transaction. The set_config('app.current_tenant', tenant_id, true) function does the same when its last argument is true. So the safe shape is:
BEGIN;
SELECT set_config('app.current_tenant', 'tenant-id-here', true);
-- all tenant queries run here
COMMIT;
When the transaction ends, the setting is gone. A borrowed connection starts with no tenant at all.
Make the missing value an error
If your policy calls current_setting('app.current_tenant') without the optional missing_ok argument, PostgreSQL raises an error when the setting does not exist instead of returning NULL. The OWASP sheet calls that a deliberate fail-closed choice. A request that forgot to set a tenant fails loudly, and you find the bug in testing.
If you pass true for missing_ok, a missing setting becomes NULL. A comparison against NULL matches no rows, which is also safe, but you lose the loud error that tells you something is wrong.
Checklist for pooled connections
- Open a transaction for every tenant-scoped request.
- Set the tenant with SET LOCAL or set_config(..., true) inside that transaction.
- Run every tenant query in the same transaction.
- Commit or roll back before the connection returns to the pool.
- Never assume a freshly borrowed connection has the right value.
- Fail closed if the tenant context is missing or invalid, and do not fall back to an unscoped query.
How to test it
The same cheat sheet recommends running requests for two tenants over reused connections and proving the second cannot observe the first one's context. A simple test pins the pool to one connection, sends a request as tenant A, then sends one as tenant B with the tenant header missing. The second request must error or return nothing.
Test through the same role, connection path and pooling mode as production. A privileged test connection can make a correct policy look broken, and a test that skips the pooler can miss the bug entirely.
Try it yourself
Click through it at your own pace, or open it full screen. Notice how a change after release gets caught on the next cycle, the same habit that catches a pooling regression after a library upgrade.
Safeguard offers SBOM and dependency scanning, so a pooler or driver update that carries an advisory shows up in your inventory. The free scan is at gold.safeguard.sh/scan.
Quick check
Is SET LOCAL outside a transaction useful?
No. It only lasts for the current transaction, so without an explicit transaction it ends at once. Open a transaction first.
Does a pooler in transaction mode fix this?
It changes how sessions are shared, but you should still set the tenant per transaction and test through the pooler you actually run.
Sources
Last reviewed: 19 October 2026.