DEV Community

Cover image for Why Tenant Context Must Be Scoped Per Transaction
Miriam Z
Miriam Z

Posted on

Why Tenant Context Must Be Scoped Per Transaction

Implementing secure multi-tenancy with PostgreSQL RLS in pooled environments.

Multi-tenancy often looks deceptively simple.

Add a tenant_id.

Enable PostgreSQL Row Level Security (RLS).

Pass the tenant in a JWT.

Done.

And for a while — it works.

But once your system starts handling real traffic, connection pooling, or parallel requests, something subtle breaks.
Not immediately. Not consistently.
Just enough to be dangerous.

This post explains why tenant context must be scoped per transaction — never per connection, and how a small design decision can quietly break tenant isolation in production.

Who is this for?

Backend and platform engineers building multi-tenant systems on PostgreSQL, especially when using ORMs, connection pooling, and strict RLS policies.


Quick links


RLS needs context — and PostgreSQL doesn’t guess

PostgreSQL RLS enforces tenant isolation by filtering rows at query time.
But the database itself has no idea which tenant is currently active.

To provide that context, we used PostgreSQL GUCs (custom runtime parameters), such as:

  • app.tenant_id

  • app.user_id

RLS policies read these values and allow access only to rows that match the current tenant.

Conceptually, it’s clean and powerful.


RLS policies rely on runtime tenant context provided by PostgreSQL GUCs.


The application sets tenant information directly inside PostgreSQL before queries are executed.


The tempting (and wrong) assumption

At first, it’s very tempting to think:

“Once a database connection knows the tenant, we’re done.”

After all:

  • Setting a GUC is cheap

  • Connections are reused

  • Why not set the tenant once and keep using it?

This assumption seems reasonable — until connection pooling enters the picture.

Connection pooling quietly breaks tenant isolation

In modern systems, database connections are pooled and reused aggressively.

That means:

  • A connection that served tenant B a moment ago

  • Might now serve tenant A

If tenant context is tied to the connection, this happens:

  1. Request for tenant B sets app.tenant_id = B
  2. The request completes
  3. The connection returns to the pool
  4. A new request for tenant A reuses the same connection
  5. The tenant context is still B

No error.

No exception.

Just incorrect data.


This diagram shows how tenant context can “stick” to a reused connection.


Why this bug is especially dangerous

This is not a bug that fails loudly.

  • It appears only under load
  • It depends on timing
  • It may never reproduce locally
  • Logs look fine
  • JWTs are correct
  • Queries are valid

And yet — tenant isolation is broken.

These are exactly the kinds of bugs that make it to production.


The rule that fixes the problem

We eventually arrived at a strict rule:

Tenant context must live inside a transaction, not on a connection.

Why this works

  • Transactions are short-lived
  • They have a clear lifecycle
  • PostgreSQL automatically cleans up GUCs when a transaction ends
  • Context cannot leak between requests

The safe flow

  1. Begin transaction
  2. Set app.tenant_id
  3. Execute queries
  4. Commit / rollback
  5. Tenant context is gone


Same system, same connection pool — but now (with transaction) tenant-safe.

Transactions don’t always exist when you think they do

At this point, it’s tempting to assume that once tenant context is scoped to a transaction, the problem is solved.

But there’s a subtle trap here.

In many systems, transactions are not created as often as we think.

Most ORMs automatically open transactions only for write operations.

Pure read flows — simple SELECTs — often run without any explicit transaction at all.

That means:

  • No transaction scope
  • No safe place to set tenant context
  • RLS executes without the correct tenant

This can happen in very common scenarios:

  • Direct API GET requests
  • Read-only jobs or background tasks
  • Any flow that doesn’t call SaveChanges (or its equivalent)

If tenant isolation matters, every database interaction must run inside an explicit transaction, even when no data is being modified.


Read-only queries can bypass transaction boundaries unless explicitly enforced.


Why per-transaction scoping is the only safe option

At this point, the conclusion becomes unavoidable.

Connections are shared.

Transactions are isolated.

Tenant context is sensitive state, and sensitive state must be:

  • Explicit
  • Short-lived
  • Scoped
  • Automatically cleaned up

Transactions give us exactly that boundary.

Anything else may work:

  • In development
  • Under light load
  • In happy-path testing

But in a pooled, concurrent, real-world system — it will eventually fail.

Not always.

Not predictably.

But inevitably.


Key takeaway

If you remember only one thing from this post, let it be this:

In a pooled environment, connections are not yours.

Transactions are.

Treat tenant context like sensitive data:

  • Set it explicitly
  • Scope it narrowly
  • Tie it to a transaction lifecycle
  • Let the database clean it up for you

That’s the difference between “it works locally” and “it’s safe in production”.


Quick summary

  • Tenant per connection — unsafe
  • Implicit context — fragile

  • Tenant per transaction — safe

  • Explicit lifecycle — predictable


Final note

Have you ever run into tenant isolation bugs caused by connection pooling or missing transaction boundaries?

I’d love to hear how you handled it — or what surprised you the most.

Feel free to leave a comment or ask a question below.

Top comments (3)

Collapse
 
n_schwartz profile image
Schwartz

An outstanding post that demonstrates a very high level of expertise and real-world experience.
It provides valuable insights into building and operating complex systems, and the lessons here are useful not only for multi-tenant architectures, but for day-to-day engineering decisions as well.
A great example of how small design choices can have serious real-world impact.

Thanks for sharing — this was a great read.

Collapse
 
_5426a7b07edbb5c03a09 profile image
רבקה אשר

Very strong post.
It clearly comes from real production experience, not theory.
The precision around the distinction between connection scope and transaction scope is exactly what makes the difference.

Collapse
 
m_zinger_2fc60eb3f3897908 profile image
Miriam Z

thank you!!