skip to content

When application servers share pooled database connections, how do you tell the database which tenant the current request belongs to so row policies can use it, and what can go wrong with that mechanism?

level: seniorimportance: must knowfreq 40%

answer

  1. Bind context to the transaction, not the connection
  2. set_config(key, value, true) → reverted on commit and rollback
  3. Missing context must mean zero rows, not all rows
  4. Transaction-pooling proxies destroy session state
  5. Tenant comes from the verified principal, never a raw header

basics

~20 s

Set a namespaced session variable at the start of each transaction (a set_config-style call read back by current_setting in the policy), scoped to the transaction so it disappears on commit or rollback. Danger: a value left on a pooled connection is inherited by the next request's tenant.

solid answer

~60 s

Policies need session context, and with a shared pool the connection outlives the request, so the context must be bound to the *unit of work*, not the connection. My pattern: open a transaction, set a namespaced setting such as `app.tenant_id` **transaction-scoped**, run the work, commit. Transaction scope means the value is discarded at commit or rollback, so the connection returns to the pool clean and nothing survives into the next request. If I must set it session-wide, I set it in a pool checkout hook and reset it on release — and I accept that any leak between the two is a cross-tenant read. Things that go wrong: an early-return path that skips the set and inherits the previous value; autocommit statements running outside the transaction that set it; a transaction-pooling proxy that reassigns backends mid-session; passing an unvalidated header straight into the setting; and code paths where the application itself can call `set_config`, which makes the policy trivially bypassable. The stronger variant is deriving the tenant from an authenticated database role instead of a settable variable.

code

sql · 6 lines
sql
BEGIN;
SELECT set_config('app.tenant_id', $1, true);  -- true = local to this transaction

SELECT id, total FROM invoice;   -- policy filters to $1

COMMIT;  -- app.tenant_id is discarded here

go deeper

for a junior

Know that the database needs to be told the tenant per request, typically through a session setting the policy reads back.

for a middle

Explain transaction-scoped versus session-scoped settings and why a pooled connection can otherwise carry the previous request's tenant.

for a senior

Cover the lifecycle end to end: where the tenant value is authenticated, parameterized setting, rollback behaviour, proxy pooling modes, and the reuse test that catches leaks.

for a principal

Discuss trust boundaries: whether the application role should be able to choose its own context at all, and the tradeoffs of privileged setter functions or per-tenant roles versus pool efficiency.

## The problem A row policy is only as good as the identity it reads. In a two-tier design where each end user has their own database login, the policy can just use the current role. In a modern service, dozens of requests for different tenants share a handful of pooled connections owned by one application role. The database sees one identity; the policy needs the *request's* identity. ## The session-variable pattern The standard answer is a namespaced runtime setting: the application writes `app.tenant_id` into the session, and the policy reads it back with a `current_setting`-style function. The namespace prefix matters — engines only allow custom settings under a dotted namespace, and it keeps the key from colliding with server parameters. The decisive detail is **scope**: - *Session-scoped* values persist until the connection closes or something resets them. On a pooled connection that is exactly the leak: request A sets tenant 7, request B forgets to set anything, and B's queries run as tenant 7. - *Transaction-scoped* values (a `set_config(key, value, true)`-style call, or the `SET LOCAL` form) are reverted at commit **and** rollback. The connection can never carry a stale tenant back into the pool. So the rule is: begin transaction, set the context transaction-scoped, do all the work inside that transaction, commit. Anything running outside a transaction (autocommit health checks, a stray `SELECT` before `BEGIN`) sees no context and, because policies fail closed, sees nothing — a safe failure, and a good reason not to make the missing-context case error out into a wide-open default. ## Where teams get it wrong 1. **Setting once at checkout, resetting on release.** Workable, but the window between checkout and release must be leak-free: an exception path that returns the connection without the reset, or a pool that hands out the same connection to a background job, poisons the next user. Transaction scope removes the whole class. 2. **Connection proxies in transaction-pooling mode.** These multiplex several clients over one backend and may switch backends between statements. Any session-level state, including your tenant setting, becomes unreliable. Either use session pooling, or keep everything inside one transaction so the proxy pins the backend for its duration. 3. **Trusting client input.** The tenant id must come from the server-side authenticated principal — the validated token claim, resolved and authorized — not from a request header or query parameter passed through. RLS enforces the predicate; it does not vouch for the value you fed it. 4. **Injecting it.** Building the context statement by string-concatenating the id invites injection into a security-critical statement. Use a parameterized `set_config(...)` call, never `SET app.tenant_id = '<interpolated>'`. 5. **Letting the application change it.** If the same role that runs ordinary queries can also call `set_config`, then any code (or injection) can switch tenants at will. RLS then protects only against *accidents*, not attackers. Hardening options: set the context through a `SECURITY DEFINER`-style function that validates the tenant against a membership table, put the setting behind a proxy/connection-init the app cannot reach, or use a per-tenant role and `SET ROLE` with `NOSET`-style restrictions. 6. **Forgetting the value's type and null case.** `current_setting('app.tenant_id')` on an unset key errors in some engines and returns empty in others; policies should use the missing-value-tolerant variant so an unset context means "no rows", not "query aborts" or, worse, "predicate evaluates oddly". ## The stronger alternative Where the tenant maps to a real database role, `SET ROLE` (or a dedicated login per tenant) gives the policy an identity the application cannot forge without privilege to assume that role. Costs: role explosion, catalog bloat, and pool fragmentation because a connection is now tenant-specific. Most teams take the setting-based approach and compensate with strict connection-lifecycle discipline and a hardened setter. ## Testing it The test that catches real bugs is the *reuse* test: run request A for tenant 1 on a pool of size one, then run request B for tenant 2 without setting context, and assert B sees zero rows rather than tenant 1's rows. Also test rollback: after a failed transaction, the next borrower must not inherit anything.

  • Your policy uses a session setting, and the same application role can execute arbitrary SQL. What does row-level security still protect you from, and what does it not?
    It still protects against every accidental omission: a query missing its tenant predicate, a report joining the wrong way, an ORM path nobody scoped. It does not protect against an attacker who achieves arbitrary SQL execution, because they can simply set the context to another tenant. To close that, the setter must be privileged — a validating security-definer function, a connection-init the app cannot issue, or a per-tenant role — so that the application role can read the context but not choose it.
  • How does a connection proxy running in transaction-pooling mode interact with this approach?
    In transaction pooling the proxy may give each transaction a different backend, so any session-level state set outside a transaction is unreliable or lost. Transaction-scoped context works because the proxy pins one backend for the life of the transaction, and the setting is discarded at commit anyway. Session-scoped context plus transaction pooling is the combination that produces intermittent, near-undebuggable cross-tenant reads.

saying these in an interview costs you the question

  • Setting the tenant session-wide and relying on remembering to reset it
  • Passing a client-supplied tenant header straight into the session setting
  • String-concatenating the tenant id into a SET statement
  • Assuming a missing context should fall back to seeing everything
  • Ignoring that an application able to run arbitrary SQL can set the context itself

context