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
- The tempting (and wrong) assumption
- Connection pooling quietly breaks tenant isolation
- Transactions don’t always exist when you think they do
- Why per-transaction scoping is the only safe option
- Key takeaway
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_idapp.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:
- Request for tenant B sets
app.tenant_id = B - The request completes
- The connection returns to the pool
- A new request for tenant A reuses the same connection
- 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
- Begin transaction
- Set
app.tenant_id - Execute queries
- Commit / rollback
- 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)
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.
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.
thank you!!