Skip to content

← Writing

engineering

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:

  • withoutGlobalScope calls. 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 (its tenant_id rides 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 own where('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_id from 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.