<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: Miriam Z</title>
    <description>The latest articles on Forem by Miriam Z (@m_zinger_2fc60eb3f3897908).</description>
    <link>https://forem.com/m_zinger_2fc60eb3f3897908</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3692603%2F92f6a95b-550d-4edb-b2bc-c43f1a1e8dcd.jpg</url>
      <title>Forem: Miriam Z</title>
      <link>https://forem.com/m_zinger_2fc60eb3f3897908</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/m_zinger_2fc60eb3f3897908"/>
    <language>en</language>
    <item>
      <title>Why Tenant Context Must Be Scoped Per Transaction</title>
      <dc:creator>Miriam Z</dc:creator>
      <pubDate>Sun, 04 Jan 2026 19:08:27 +0000</pubDate>
      <link>https://forem.com/m_zinger_2fc60eb3f3897908/why-tenant-context-must-be-scoped-per-transaction-3aop</link>
      <guid>https://forem.com/m_zinger_2fc60eb3f3897908/why-tenant-context-must-be-scoped-per-transaction-3aop</guid>
      <description>&lt;p&gt;&lt;strong&gt;&lt;em&gt;Implementing secure multi-tenancy with PostgreSQL RLS in pooled environments.&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Multi-tenancy often looks deceptively simple.&lt;/p&gt;

&lt;p&gt;Add a &lt;code&gt;tenant_id&lt;/code&gt;.&lt;br&gt;&lt;br&gt;
Enable PostgreSQL Row Level Security (RLS).&lt;br&gt;&lt;br&gt;
Pass the tenant in a JWT.&lt;br&gt;&lt;br&gt;
Done.&lt;/p&gt;

&lt;p&gt;And for a while — it works.&lt;/p&gt;

&lt;p&gt;But once your system starts handling real traffic, connection pooling, or parallel requests, something subtle breaks.&lt;br&gt;
Not immediately. Not consistently.&lt;br&gt;
Just enough to be dangerous.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  &lt;strong&gt;Who is this for?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Backend and platform engineers building multi-tenant systems on PostgreSQL, especially when using ORMs, connection pooling, and strict RLS policies.&lt;/p&gt;




&lt;h3&gt;
  
  
  Quick links
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;RLS needs context — and PostgreSQL doesn’t guess&lt;/li&gt;
&lt;li&gt;The tempting (and wrong) assumption&lt;/li&gt;
&lt;li&gt;Connection pooling quietly breaks tenant isolation&lt;/li&gt;
&lt;li&gt;Transactions don’t always exist when you think they do&lt;/li&gt;
&lt;li&gt;Why per-transaction scoping is the only safe option&lt;/li&gt;
&lt;li&gt;Key takeaway&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  RLS needs context — and PostgreSQL doesn’t guess
&lt;/h2&gt;

&lt;p&gt;PostgreSQL RLS enforces tenant isolation by filtering rows at query time.&lt;br&gt;
But the database itself has no idea which tenant is currently active.&lt;/p&gt;

&lt;p&gt;To provide that context, we used &lt;strong&gt;PostgreSQL GUCs&lt;/strong&gt; (custom runtime parameters), such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;app.tenant_id&lt;/code&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;app.user_id&lt;/code&gt;&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;RLS policies read these values and allow access only to rows that match the current tenant.&lt;/p&gt;

&lt;p&gt;Conceptually, it’s clean and powerful.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffz84b37rs89ao8xvjxmo.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffz84b37rs89ao8xvjxmo.jpg" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;RLS policies rely on runtime tenant context provided by PostgreSQL GUCs.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fle2lhb10j2pluc0os5n4.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fle2lhb10j2pluc0os5n4.jpg" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The application sets tenant information directly inside PostgreSQL before queries are executed.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The tempting (and wrong) assumption
&lt;/h2&gt;

&lt;p&gt;At first, it’s very tempting to think:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“Once a database connection knows the tenant, we’re done.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;After all:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Setting a GUC is cheap&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Connections are reused&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Why not set the tenant once and keep using it?&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  This assumption seems reasonable — until connection pooling enters the picture.
&lt;/h2&gt;

&lt;h2&gt;
  
  
  Connection pooling quietly breaks tenant isolation
&lt;/h2&gt;

&lt;p&gt;In modern systems, database connections are pooled and reused aggressively.&lt;/p&gt;

&lt;p&gt;That means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;A connection that served tenant B a moment ago&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Might now serve tenant A&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If tenant context is tied to the connection, this happens:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Request for tenant B sets &lt;code&gt;app.tenant_id = B&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;The request completes
&lt;/li&gt;
&lt;li&gt;The connection returns to the pool
&lt;/li&gt;
&lt;li&gt;A new request for tenant A reuses the same connection
&lt;/li&gt;
&lt;li&gt;The tenant context is still &lt;strong&gt;B&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No error.&lt;br&gt;&lt;br&gt;
No exception.&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Just incorrect data.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxwkdtik7r9abz1ztjjsb.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxwkdtik7r9abz1ztjjsb.jpg" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;This diagram shows how tenant context can “stick” to a reused connection.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Why this bug is especially dangerous
&lt;/h2&gt;

&lt;p&gt;This is not a bug that fails loudly.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It appears only under load
&lt;/li&gt;
&lt;li&gt;It depends on timing
&lt;/li&gt;
&lt;li&gt;It may never reproduce locally
&lt;/li&gt;
&lt;li&gt;Logs look fine
&lt;/li&gt;
&lt;li&gt;JWTs are correct
&lt;/li&gt;
&lt;li&gt;Queries are valid
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And yet — &lt;strong&gt;tenant isolation is broken&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;These are exactly the kinds of bugs that make it to production.&lt;/p&gt;




&lt;h2&gt;
  
  
  The rule that fixes the problem
&lt;/h2&gt;

&lt;p&gt;We eventually arrived at a strict rule:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Tenant context must live inside a transaction, not on a connection.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Why this works
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Transactions are short-lived
&lt;/li&gt;
&lt;li&gt;They have a clear lifecycle
&lt;/li&gt;
&lt;li&gt;PostgreSQL automatically cleans up GUCs when a transaction ends
&lt;/li&gt;
&lt;li&gt;Context cannot leak between requests
&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The safe flow
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Begin transaction
&lt;/li&gt;
&lt;li&gt;Set &lt;code&gt;app.tenant_id&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Execute queries
&lt;/li&gt;
&lt;li&gt;Commit / rollback
&lt;/li&gt;
&lt;li&gt;Tenant context is gone
&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvyw36jtnyuteik94bzdr.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvyw36jtnyuteik94bzdr.jpg" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Same system, same connection pool — but now (with transaction) tenant-safe.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Transactions don’t always exist when you think they do
&lt;/h2&gt;

&lt;p&gt;At this point, it’s tempting to assume that once tenant context is scoped to a transaction, the problem is solved.&lt;/p&gt;

&lt;p&gt;But there’s a subtle trap here.&lt;/p&gt;

&lt;p&gt;In many systems, &lt;strong&gt;transactions are not created as often as we think&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Most ORMs automatically open transactions only for write operations.&lt;br&gt;&lt;br&gt;
Pure read flows — simple &lt;code&gt;SELECT&lt;/code&gt;s — often run &lt;strong&gt;without any explicit transaction at all&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No transaction scope&lt;/li&gt;
&lt;li&gt;No safe place to set tenant context&lt;/li&gt;
&lt;li&gt;RLS executes without the correct tenant&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This can happen in very common scenarios:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Direct API GET requests&lt;/li&gt;
&lt;li&gt;Read-only jobs or background tasks&lt;/li&gt;
&lt;li&gt;Any flow that doesn’t call &lt;code&gt;SaveChanges&lt;/code&gt; (or its equivalent)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If tenant isolation matters, &lt;strong&gt;every database interaction must run inside an explicit transaction&lt;/strong&gt;, even when no data is being modified.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpswg2ya1ylltogubl992.JPG" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpswg2ya1ylltogubl992.JPG" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Read-only queries can bypass transaction boundaries unless explicitly enforced.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Why per-transaction scoping is the only safe option
&lt;/h2&gt;

&lt;p&gt;At this point, the conclusion becomes unavoidable.&lt;/p&gt;

&lt;p&gt;Connections are shared.&lt;br&gt;&lt;br&gt;
Transactions are isolated.&lt;/p&gt;

&lt;p&gt;Tenant context is sensitive state, and sensitive state must be:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Explicit&lt;/li&gt;
&lt;li&gt;Short-lived&lt;/li&gt;
&lt;li&gt;Scoped&lt;/li&gt;
&lt;li&gt;Automatically cleaned up&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Transactions give us exactly that boundary.&lt;/p&gt;

&lt;p&gt;Anything else may work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;In development&lt;/li&gt;
&lt;li&gt;Under light load&lt;/li&gt;
&lt;li&gt;In happy-path testing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But in a pooled, concurrent, real-world system — it will eventually fail.&lt;/p&gt;

&lt;p&gt;Not always.&lt;br&gt;&lt;br&gt;
Not predictably.&lt;br&gt;&lt;br&gt;
But inevitably.&lt;/p&gt;




&lt;h2&gt;
  
  
  Key takeaway
&lt;/h2&gt;

&lt;p&gt;If you remember only one thing from this post, let it be this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;In a pooled environment, &lt;strong&gt;connections are not yours&lt;/strong&gt;.&lt;br&gt;&lt;br&gt;
Transactions are.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Treat tenant context like sensitive data:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Set it explicitly&lt;/li&gt;
&lt;li&gt;Scope it narrowly&lt;/li&gt;
&lt;li&gt;Tie it to a transaction lifecycle&lt;/li&gt;
&lt;li&gt;Let the database clean it up for you&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That’s the difference between &lt;em&gt;“it works locally”&lt;/em&gt; and &lt;em&gt;“it’s safe in production”&lt;/em&gt;.&lt;/p&gt;




&lt;h3&gt;
  
  
  Quick summary
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;❌ &lt;strong&gt;Tenant per connection&lt;/strong&gt; — unsafe
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;❌ &lt;strong&gt;Implicit context&lt;/strong&gt; — fragile  &lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;✅ &lt;strong&gt;Tenant per transaction&lt;/strong&gt; — safe  &lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;✅ &lt;strong&gt;Explicit lifecycle&lt;/strong&gt; — predictable &lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Final note
&lt;/h2&gt;

&lt;p&gt;Have you ever run into tenant isolation bugs caused by connection pooling or missing transaction boundaries?&lt;/p&gt;

&lt;p&gt;I’d love to hear how you handled it — or what surprised you the most.&lt;br&gt;&lt;br&gt;
Feel free to leave a comment or ask a question below.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>multitenancy</category>
      <category>backend</category>
    </item>
  </channel>
</rss>
