<?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>DEV Community: feng</title>
    <description>The latest articles on DEV Community by feng (@linfeng).</description>
    <link>https://dev.to/linfeng</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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4169426%2F42198663-9690-4e62-9268-1436e36e2691.png</url>
      <title>DEV Community: feng</title>
      <link>https://dev.to/linfeng</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/linfeng"/>
    <language>en</language>
    <item>
      <title>I Built 40+ Multi-Tenant SaaS Apps. Here Are 7 Bugs That Leak Data</title>
      <dc:creator>feng</dc:creator>
      <pubDate>Thu, 08 Oct 2026 04:13:02 +0000</pubDate>
      <link>https://dev.to/linfeng/i-shipped-40-multi-tenant-saas-apps-these-7-tenant-isolation-bugs-still-bite-indie-devs-5beb</link>
      <guid>https://dev.to/linfeng/i-shipped-40-multi-tenant-saas-apps-these-7-tenant-isolation-bugs-still-bite-indie-devs-5beb</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;What’s the worst tenant-isolation bug you’ve shipped?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;After ~16 years building multi-tenant systems (PHP/Java, enterprise clients + a few indie side projects), I've lost count of how many "it works in dev, passed tests, then leaked data in prod" incidents I've debugged.&lt;/p&gt;

&lt;p&gt;The worst part? &lt;strong&gt;None of these throw an exception.&lt;/strong&gt; Your test suite is green. Your customers don't complain until a sharp one exports &lt;em&gt;their&lt;/em&gt; user list and spots someone else's email.&lt;/p&gt;

&lt;p&gt;Here are the 7 I've personally shipped, reviewed, or cleaned up. If you're building a SaaS solo, skim this before you add your 3rd tenant.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. &lt;code&gt;users&lt;/code&gt; table with no &lt;code&gt;tenant_id&lt;/code&gt; at all
&lt;/h2&gt;

&lt;p&gt;The classic. You scaffolded auth early, added tenancy later, and the original &lt;code&gt;users&lt;/code&gt; table never got the column.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- ❌ still in prod somewhere&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;password&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fix is easy &lt;em&gt;to write&lt;/em&gt; and painful &lt;em&gt;to migrate&lt;/em&gt;: backfill the column, add a global scope, and lock the model so it can never be queried without it.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. A global scope that's silently bypassed
&lt;/h2&gt;

&lt;p&gt;ThinkORM / Eloquent global scopes are great — until someone writes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;withoutGlobalScope&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// "just for the admin panel, trust me"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Six months later that admin panel is a CSV export. The scope was bypassed &lt;em&gt;once&lt;/em&gt; and forgot to add it back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule I now enforce:&lt;/strong&gt; admin-level reads go through a separate read model, never &lt;code&gt;withoutGlobalScope()&lt;/code&gt; on the tenant model.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. JOINs that forget the tenant filter
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Db&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'invoices'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'users'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'users.id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'='&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'invoices.user_id'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Looks fine. Invoices have &lt;code&gt;tenant_id&lt;/code&gt;, users don't (see #1), join silently crosses tenants.&lt;/p&gt;

&lt;p&gt;Static scan catches this faster than a test ever will.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Soft-delete ID recycling across tenants
&lt;/h2&gt;

&lt;p&gt;This one is sneaky and &lt;em&gt;fatal&lt;/em&gt; for audit logs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// auto-increment reuses the id after soft-delete&lt;/span&gt;
&lt;span class="nv"&gt;$nextId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Invoice&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'id'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// ❌&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tenant A deletes invoice #1042, Tenant B's new invoice reuses #1042, and your audit trail now attributes A's payment to B.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; never compute IDs yourself, and if you soft-delete, count with &lt;code&gt;withTrashed()&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. JWT / token carries &lt;code&gt;user_id&lt;/code&gt; but not &lt;code&gt;app_id&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Token decodes to &lt;code&gt;user_id=8821&lt;/code&gt;, controller trusts it, fetches the user… but never re-checks that &lt;code&gt;user_id&lt;/code&gt; belongs to the &lt;em&gt;current&lt;/em&gt; tenant from the Host header.&lt;/p&gt;

&lt;p&gt;One mismatched header = full cross-tenant read.&lt;/p&gt;

&lt;p&gt;I now bake &lt;code&gt;app_id&lt;/code&gt; into the token claims and reject any request where token &lt;code&gt;app_id&lt;/code&gt; ≠ resolved tenant from domain/appid middleware. Non-negotiable.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. File uploads keyed by filename, not tenant-scoped path
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;/uploads/contract.pdf&lt;/code&gt; → later overwritten by another tenant. Or worse, a guessed URL &lt;code&gt;/uploads/10002/contract.pdf&lt;/code&gt; is directly reachable.&lt;/p&gt;

&lt;p&gt;Storage keys &lt;strong&gt;must&lt;/strong&gt; be &lt;code&gt;tenant_id/uuid.ext&lt;/code&gt;, and the download route must re-auth against the tenant scope, not just serve the file.&lt;/p&gt;




&lt;h2&gt;
  
  
  7. Menu/permission cache shared across tenants
&lt;/h2&gt;

&lt;p&gt;A tenant-level RBAC cache keyed by &lt;code&gt;role_id&lt;/code&gt; alone (not &lt;code&gt;tenant_id:role_id&lt;/code&gt;) means Tenant A's admin edits a role, and Tenant B suddenly inherits A's menu tree.&lt;/p&gt;

&lt;p&gt;Cache keys are data. Treat them like a DB row.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I do now instead of praying
&lt;/h2&gt;

&lt;p&gt;I stopped relying on code review alone and wrote a tiny static scanner that ingests a SQL dump + a source zip and just flags:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;missing &lt;code&gt;tenant_id&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;scope-bypass calls&lt;/li&gt;
&lt;li&gt;JOIN-without-filter&lt;/li&gt;
&lt;li&gt;shared cache keys&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Red/yellow/green table, one-page report.&lt;/p&gt;

&lt;p&gt;It started as an internal lint rule for my own webman/ThinkPHP projects. Figured other indie devs hit the same walls, so I kept it as a small side tool.&lt;/p&gt;

&lt;p&gt;If you want a &lt;strong&gt;sample report on your own SQL dump&lt;/strong&gt; (no signup, I don't execute your code, just parses text), drop a comment or DM me — happy to run one and send it back. Not selling anything yet, just curious how many of these 7 are sitting in your schema 😅&lt;/p&gt;




&lt;p&gt;&lt;em&gt;— Built a bunch of these the hard way; happy to trade war stories in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>php</category>
      <category>saas</category>
      <category>security</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
