Multi-Tenant Authorization: One Database, Many Customers, Zero Leaks
· Jerwin Arnado · 7 min read ·
Everything in parts one and two assumed one organization’s users sharing one app. SaaS breaks that assumption: Lucas Clinic serves many clinics from one Laravel codebase and one database, and every clinic must behave as if it’s the only one there. That adds a new axis to authorization — and a new worst-case bug. A broken policy over-grants inside a company; a tenant leak shows customer A’s patients to customer B. One is an incident. The other is the end of the contract, and possibly the product.
Pick your isolation level first
| Model | Isolation | Cost | Reach for it when |
|---|---|---|---|
Shared DB, tenant_id column |
logical (code-enforced) | cheapest, easiest ops | default — most SaaS, including mine |
| Schema/DB per tenant | physical | migrations × N, connection juggling | contracts/regulators demand it |
| Hybrid (shared + big tenants carved out) | mixed | both kinds of complexity | one whale customer forces it |
Shared-database-with-a-column is where most products rightly start — one migration path,
one backup, trivial onboarding. Its price is that isolation is only as strong as your
discipline, because every single query must remember the WHERE tenant_id = ?. Which
brings us to the bug class.
The bug class: one forgotten WHERE
The leak almost never looks dramatic. It looks like this:
// Route: GET /invoices/{invoice}
public function show(Invoice $invoice)
{
return new InvoiceResource($invoice); // findOrFail by ID — ANY tenant's ID
}
Sequential IDs, no tenant filter: /invoices/123, /invoices/124… someone else’s
billing. This is the row question from part one in its most
dangerous form — an IDOR across a paying-customer boundary. And “remember to add the
where clause in all 300 queries” is not a strategy; humans lose that game. The fix is
making the filtered query the default the code can’t forget.
Global scope: leak-proof by default
One trait, applied to every tenant-owned model:
trait BelongsToTenant
{
protected static function bootBelongsToTenant(): void
{
// Every query on this model is tenant-filtered — reads, updates, deletes
static::addGlobalScope('tenant', function (Builder $query) {
$query->where($query->getModel()->qualifyColumn('tenant_id'),
auth()->user()?->tenant_id);
});
// Every insert is stamped — the request never supplies tenant_id
static::creating(function (Model $model) {
$model->tenant_id ??= auth()->user()?->tenant_id;
});
}
}
class Invoice extends Model
{
use BelongsToTenant;
}
Now the leaky controller from above is safe without changing a line of it: route model binding runs through Eloquent, the global scope appends the tenant filter, and a foreign ID resolves to 404 — not 403, which would confirm the invoice exists. Not knowing is the point.
Note the null-safety: an unauthenticated or system context makes the scope filter on
null, which matches nothing. Failing closed is correct — but remember that behavior,
because it’s exactly what breaks in queues below.
Tenancy is not authorization
Tempting conclusion: “the scope handles security now.” No — it handles one axis. The scope answers which rows exist in your universe; the policy answers what you may do to them. Inside a clinic, the receptionist and the doctor see the same tenant’s rows and are allowed very different things:
class MedicalRecordPolicy
{
public function view(User $user, MedicalRecord $record): bool
{
// Same tenant is a GIVEN here — the scope already guaranteed it.
// The policy handles the human question: role + assignment.
return $user->hasRole('physician')
&& $record->patient->assignedPhysicians->contains($user);
}
}
Collapsing the two axes into one mechanism is how you get either a scope full of role
logic or policies re-checking tenant_id everywhere. Keep the layers: scope = which
universe, policy = what’s allowed inside it. Both tested, per
part two.
The escape hatches will bite you
Every global scope grows escape hatches, and every leak I’ve had to chase lived in one:
withoutGlobalScopecalls. Sometimes legitimate — a cross-tenant admin report — but each one is a hole you personally punched. Grep-audit them; every use needs a comment saying why, and ideally a dedicated method (Invoice::allTenants()) so they’re findable.- Queued jobs. No authenticated user in a worker → the scope filters on
null→ the job silently processes zero rows and reports success. The fix is carrying tenancy in the payload, never deriving it from auth: pass the model (itstenant_idrides along) or the tenant ID explicitly, and have the job set the context before touching models. - Artisan commands and schedulers. Same problem, worse visibility: a nightly
billing command with no tenant context either does nothing or — if someone “fixed” it
with
withoutGlobalScope— does everything across all tenants at once. Loop tenants explicitly:Tenant::each(fn ($t) => $this->runFor($t)). - Raw queries.
DB::table('invoices')never heard of your scope. Every raw query in a multi-tenant app carries its ownwhere('tenant_id', ...)— reviewed like the security code it is.
Caveats and best practices
- Uniqueness is per-tenant now. A global
unique('email')on patients means two clinics can’t both have[email protected]. Composite it:$table->unique(['tenant_id', 'email']). - Cache keys need the tenant in them.
cache()->remember("dashboard-stats", ...)is a cross-tenant leak through the caching layer. Prefix every tenant-varying key:"t{$tenantId}:dashboard-stats". - Never accept
tenant_idfrom the request. Not in the body, not in a hidden field, not in a header you don’t cryptographically trust. It comes from the authenticated session, or (for path-based tenancy) from middleware that verified membership. - Test the cross-tenant 404 explicitly. Two tenants in the factory setup, tenant B’s user requests tenant A’s row, assert 404 — one feature test per resource. This is the multi-tenant sibling of part two’s “prove the 403 is wired.”
- Seed two tenants in every test, not one. A single-tenant test suite can’t leak by construction, so it proves nothing about isolation. The second tenant is what makes “assert only 3 results” mean something.
Conclusion
Axis 1 → tenancy: global scope, auto-stamped inserts, cross-tenant = 404
Axis 2 → authorization: policies unchanged, operating inside the tenant's universe
Hatches → jobs, commands, raw SQL, withoutGlobalScope — where every leak hides
Tests → two tenants always; the cross-tenant 404 is a permanent fixture
Multi-tenancy doesn’t replace the authorization model from parts one and two — it wraps a harder boundary around it, one where the failure mode is losing a customer instead of filing a bug. Make the tenant filter the default the code can’t forget, keep policies for the human questions, and treat every escape hatch as the next incident’s address. Next in the series: proving what happened after the fact — audit trails.