Skip to content
~/benhattab
Back to notes
·2 min read

A multi-tenant scope must fail closed

Cross-tenant data leaks are never a decision — they are an oversight. Why I make my scopes return zero rows when the tenant is missing, instead of everything.

LaravelMulti-tenancySecurity

In a multi-tenant SaaS, nobody ever decides to let one customer see another's data. It happens because a query got written without the tenant_id filter — in an export added six months later, in a report written on a Friday evening, in a background job nobody reviewed.

That is why "remember to filter by tenant" is not a security strategy. It is an instruction, and instructions get forgotten.

The trap in the naive implementation

The usual approach is to add a global scope to the tenant-owned models:

public function apply(Builder $builder, Model $model): void
{
    $tenant = app(TenantManager::class)->current();

    if ($tenant) {
        $builder->where('tenant_id', $tenant->id);
    }
}

This looks reasonable. It is dangerous.

Read the condition again: when there is no tenant, no filter is applied at all. The query goes out with no where clause and returns rows belonging to every customer on the platform. The "I don't know who you are" case behaves exactly like the "you may see everything" case.

And the tenant can be missing for entirely mundane reasons: a queued job that does not carry request context, an artisan command, an inbound webhook, a poorly isolated test, a middleware that did not run on that route.

Invert the default

The fix is one line:

public function apply(Builder $builder, Model $model): void
{
    $tenant = app(TenantManager::class)->current();

    if (! $tenant) {
        $builder->whereRaw('1 = 0');   // no tenant, no rows
        return;
    }

    $builder->where('tenant_id', $tenant->id);
}

With no resolvable tenant, the query returns nothing.

The benefit is not theoretical, it is operational: a resolution bug now produces an empty page. An empty page gets noticed, reported and fixed the same day. A silent cross-tenant data spill does not get noticed — until a customer is the one telling you about it.

The exception you have to accept

One model cannot follow this rule: the user model.

Authentication has to find a user before knowing which tenant they belong to — the user record is what carries that information. If User is scoped closed by default, nobody can log in at all.

That exception must be explicit and singular. One documented exception is a decision; three exceptions accumulated across sprints is a sieve.

Make the escape hatch loud

Legitimate cross-tenant reads still exist: platform analytics, the back office, billing. Those cases have to be possible — but they must be visible in the code:

// Platform analytics: deliberately cross-tenant.
Order::withoutGlobalScopes()
    ->whereIn('tenant_id', $tenantIds)
    ->sum('total');

Nobody types withoutGlobalScopes() by accident. In review that line jumps off the screen — which is precisely the point.

The general principle fits in one sentence, and it reaches well beyond multi-tenancy:

Make the safe case automatic and the dangerous case verbose.

Anything that depends on a developer's vigilance at three in the morning will eventually fail. Anything that depends on the structure of the code will not.