Tenant isolation isn’t your hardest multi-tenant problem anymore; it’s tenant context propagation. You can have textbook Postgres Row Level Security (RLS) and still leak data because the tenant identity gets lost, reused, or silently substituted in pools, background jobs, async queues, and shared caches.
The uncomfortable truth is that most teams don’t fail because they picked the “wrong” isolation strategy. They fail because they relied on request-thread discipline in a system that spends a lot of time not being a request thread.
What “tenant context propagation” actually means
Tenant context is the minimum set of information required to make every downstream operation tenant-safe.
In a typical SaaS, that context is at least:
- tenant identifier (immutable, canonical)
- principal identifier (user, service account)
- authorisation scope (roles/claims, sometimes per-tenant)
- data-plane intent (read vs write, or the specific capability)
- correlation identifiers (trace/span IDs so you can prove what happened)
Propagation means:
- capturing that context at the trust boundary (HTTP/gRPC ingress, worker dequeue)
- carrying it across execution boundaries (threads, async tasks, futures/promises)
- attaching it to every outbound call (DB, cache, queue, storage)
- resetting it deterministically when the unit of work is done
The key difference from “tenant isolation” as people usually discuss it: propagation is about how the correct tenant identity survives contact with real infrastructure.
Why RLS is necessary, but not sufficient
RLS is valuable because it moves enforcement into the database and makes “forgot to add WHERE tenant_id = …” much harder to ship.
But RLS tends to be implemented using per-session or per-transaction settings (for example via current_setting() / set_config() patterns), and that’s where the leaks start when you introduce pooling and async boundaries.
One very common failure mode is “tenant A’s DB session state is accidentally reused for tenant B”. It is especially easy to do with pooled drivers and poolers if you set tenant state on one connection and later run queries on another, or if you misunderstand session vs transaction scope. A recent Stack Overflow thread walks through exactly this class of mistake: using pool.query() (which may use different sessions per statement) and using SET LOCAL outside an explicit transaction, which effectively makes the setting vanish immediately. (stackoverflow.com)
And then there’s PgBouncer. In transaction pooling mode, session state is not preserved between transactions. PgBouncer’s own docs and cloud-provider docs call out that features relying on session state (SET variables, temp tables, advisory locks, LISTEN/NOTIFY) don’t carry over in transaction pooling. (cloud.ibm.com)
So if your “RLS tenant” is a session variable and you’re on transaction pooling, you must treat it as transaction-scoped and set it inside the transaction every time. If you don’t, you aren’t “maybe insecure”; you are running with undefined behaviour.
The three places tenant context leaks in practice
You can group the majority of incidents into three buckets.
1) Pools: session state and identity mismatch
This includes:
- app-level DB pools (JDBC/HikariCP,
pgpools, ActiveRecord pool) - external poolers (PgBouncer)
- HTTP client pools (shared headers, auth propagation)
Failure modes:
- tenant context set on connection A; query runs on connection B
- tenant context set once per request but a later internal retry uses a different execution path with no context
- transaction pooling breaks assumptions about “sticky” settings
- connection returned to pool without reset; next borrower inherits state
PgBouncer’s documentation and guidance are explicit that transaction pooling breaks session semantics; if you rely on session state, you need either session pooling or a design which re-applies state per transaction. (devcenter.heroku.com)
2) Jobs and async queues: thread locals don’t cross boundaries
Most job systems give you two distinct contexts:
- enqueue time (inside a request, you still have tenant context)
- perform time (in a worker, often on a different machine, different process, different thread)
If your application relies on thread-local storage to hold the current tenant, it’s easy to accidentally run a job with:
- no tenant
- the previous job’s tenant (stale thread-local)
- a default tenant (worse than “none”, because it looks valid)
Sidekiq’s own documentation calls out a sharp edge here: request-specific context is typically implemented via thread-local variables, but that context is not always visible in retry hooks because they run outside the middleware context. (sidekiq.org)
That’s not Sidekiq being “bad”. It’s a reminder that the job framework’s lifecycle does not align with your implicit assumptions.
3) Caches: the tenant isn’t in the key (or isn’t in the authorisation)
Cross-tenant cache leaks are uniquely nasty because:
- they often produce “correct-looking” responses
- they can persist and spread quietly
- your database logs show nothing suspicious
Redis (and caches in general) don’t know what a tenant is. If your cache key doesn’t fully encode the isolation boundary, you can serve tenant A’s computed value to tenant B.
Redis’ own multi-tenant isolation guidance explicitly highlights that cache leaks are frequently caused by missing tenant context on the read/write path (wrong prefix, wrong token leading to wrong prefix), and recommends tenant-aware key naming. (redis.io)
If you want a concrete example of how this happens in real systems: a 2026 post-mortem describes a 72-hour cross-tenant leak caused by a cache key missing one field. (darshanturakhia.com)
A concrete propagation blueprint (secure-by-default, not “remember to”)
Here is the pattern that tends to hold up under growth and incident review.
1) Define a canonical TenantContext type
Make it a proper value object/struct, not “some globals”. It should be serialisable (for job payloads) and loggable in a controlled way.
tenant_id(required)actor_id(required)scopes(required)request_id/ trace IDs (required)source(edge, internal service, job type)
Rule: if tenant_id is missing, the system must fail closed.
2) Capture context only at explicit trust boundaries
Trust boundaries include:
- inbound HTTP/gRPC (after authn/authz)
- job dequeue (message already contains tenant_id)
- internal scheduled tasks (must declare tenant strategy explicitly)
Rule: do not “discover tenant” deep in the call stack by reading random params. Make it explicit at the boundary.
3) Propagate explicitly across process boundaries
For async jobs, the only robust approach is:
- include
tenant_id(and often actor/scope) in the job payload - validate it on dequeue
- rebuild TenantContext inside the worker before doing anything else
This also gives you auditability: you can answer “which tenant did this job act on?” without reconstructing it from logs.
4) Make database tenant state transaction-scoped, and apply it automatically
If you use RLS with current_setting(), apply the tenant within the same transaction as the queries.
Concretely:
- open transaction
- set tenant context in the DB for that transaction
- run queries
- commit/rollback
If you use PgBouncer transaction pooling, treat “set tenant per transaction” as mandatory, because session state is not preserved between transactions. (cloud.ibm.com)
Rule: the data-access layer owns this. Developers shouldn’t be sprinkling “set tenant” calls around business logic.
5) Make cache isolation structural
Do not rely on “we always remember to prefix keys”.
- require cache access through a wrapper that takes TenantContext
- build keys as
tenant:{tenant_id}:...(or equivalent) consistently - where possible, enforce key patterns with ACLs / namespaces (so a bug cannot read another tenant’s keys)
Redis’ own guidance frames this as defence-in-depth: key naming plus access controls, not one or the other. (redis.io)
6) Reset/clear context at the end of every unit of work
This is the part many teams skip. You need deterministic teardown:
- request end
- job end
- batch iteration boundary
Rule: context must not be “whatever was last set on this thread”. That’s how you get once-in-100k heisenbugs.
“Do not do this”: common anti-patterns
If you want to reduce incident probability quickly, stop doing these.
- Do not store tenant context in a mutable global or thread-local without a guaranteed clear/reset.
- Do not let business logic call the cache client directly.
- Do not let job handlers infer tenant from database contents (“load record then read tenant_id”). The first query already needed the tenant.
- Do not mix PgBouncer transaction pooling with session-dependent tenant state and hope your framework “handles it”. Transaction pooling explicitly does not preserve session state. (cloud.ibm.com)
- Do not create “default tenant” behaviour as a convenience. Missing tenant should be a hard error.
A failure-mode checklist you can turn into tests
The most useful deliverable here isn’t a diagram; it’s a set of tests which fail when context leaks.
Pooling and DB
- Two concurrent requests for different tenants must never read each other’s rows.
- A request which performs multiple queries must keep tenant context consistent across those queries.
- A request which retries (due to a transient DB error) must re-establish tenant context before retrying.
- If using transaction pooling, verify tenant context is applied inside each transaction (not once per request).
Background jobs
- A job with missing tenant_id must fail before any side-effects.
- A job’s retries must preserve the same tenant_id across attempts.
- Hooks/callbacks (retry hooks, exhausted hooks) must not assume request context exists; Sidekiq explicitly warns some hooks run outside the middleware context. (sidekiq.org)
Caches
- Cache keys must include tenant_id and any other discriminator that changes the result.
- Reads and writes must both require TenantContext (same wrapper).
- A deliberately malformed request must not be able to choose an arbitrary tenant prefix.
If you can’t write a test which proves “tenant_id is required for every outbound dependency”, you’re relying on discipline.
Where codeversols fits (briefly)
This is the kind of problem that sits between architecture and operational reality: it touches database policy (RLS), poolers like PgBouncer, job frameworks, cache design, and how you structure application boundaries. At codeversols we typically help teams turn these concerns into a small, enforceable platform layer (context type, dependency wrappers, test harnesses), so individual product engineers don’t have to remember the rules on every PR.
Close
If you already have Postgres RLS, keep it. But treat it as one enforcement point in a broader system whose default behaviours (pooling, concurrency, caching, async execution) are indifferent to tenants. The fastest route to fewer cross-tenant incidents is to make tenant context a first-class value, propagate it explicitly, and build wrappers and tests that fail closed when it’s missing.



