What’s the worst tenant-isolation bug you’ve shipped?
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.
The worst part? None of these throw an exception. Your test suite is green. Your customers don't complain until a sharp one exports their user list and spots someone else's email.
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.
1. users table with no tenant_id at all
The classic. You scaffolded auth early, added tenancy later, and the original users table never got the column.
-- ❌ still in prod somewhere
CREATE TABLE users (
id INT,
email VARCHAR(255),
password VARCHAR(255)
);
Fix is easy to write and painful to migrate: backfill the column, add a global scope, and lock the model so it can never be queried without it.
2. A global scope that's silently bypassed
ThinkORM / Eloquent global scopes are great — until someone writes:
User::withoutGlobalScope()->count(); // "just for the admin panel, trust me"
Six months later that admin panel is a CSV export. The scope was bypassed once and forgot to add it back.
Rule I now enforce: admin-level reads go through a separate read model, never withoutGlobalScope() on the tenant model.
3. JOINs that forget the tenant filter
Db::table('invoices')
->join('users', 'users.id', '=', 'invoices.user_id')
->get();
Looks fine. Invoices have tenant_id, users don't (see #1), join silently crosses tenants.
Static scan catches this faster than a test ever will.
4. Soft-delete ID recycling across tenants
This one is sneaky and fatal for audit logs.
// auto-increment reuses the id after soft-delete
$nextId = Invoice::max('id') + 1; // ❌
Tenant A deletes invoice #1042, Tenant B's new invoice reuses #1042, and your audit trail now attributes A's payment to B.
Fix: never compute IDs yourself, and if you soft-delete, count with withTrashed().
5. JWT / token carries user_id but not app_id
Token decodes to user_id=8821, controller trusts it, fetches the user… but never re-checks that user_id belongs to the current tenant from the Host header.
One mismatched header = full cross-tenant read.
I now bake app_id into the token claims and reject any request where token app_id ≠ resolved tenant from domain/appid middleware. Non-negotiable.
6. File uploads keyed by filename, not tenant-scoped path
/uploads/contract.pdf → later overwritten by another tenant. Or worse, a guessed URL /uploads/10002/contract.pdf is directly reachable.
Storage keys must be tenant_id/uuid.ext, and the download route must re-auth against the tenant scope, not just serve the file.
7. Menu/permission cache shared across tenants
A tenant-level RBAC cache keyed by role_id alone (not tenant_id:role_id) means Tenant A's admin edits a role, and Tenant B suddenly inherits A's menu tree.
Cache keys are data. Treat them like a DB row.
What I do now instead of praying
I stopped relying on code review alone and wrote a tiny static scanner that ingests a SQL dump + a source zip and just flags:
- missing
tenant_id - scope-bypass calls
- JOIN-without-filter
- shared cache keys
Red/yellow/green table, one-page report.
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.
If you want a sample report on your own SQL dump (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 😅
— Built a bunch of these the hard way; happy to trade war stories in the comments.
Top comments (1)
If it helps, here’s what the static report I mentioned looks like in plain text:
Just a one-page red/yellow/green table. I’m happy to run it on a sanitized SQL DDL if anyone wants to see how many of these 7 are hiding in their schema.