TL;DR
- A multi-tenant app was resolving the active tenant from the request (subdomain/header) instead of the authenticated user.
- That makes the client the source of truth for "which tenant am I" — the wrong place for it.
- Fix: derive the tenant from the user's organization membership, enforce it in middleware, and fail closed. One test locks the behaviour.
The bug, in one sentence
The request was telling the app which tenant to load, and the app believed it.
In a multi-tenant SaaS, every query is implicitly scoped: "give me this tenant's dashboards." If the tenant ID comes from something the client controls — a subdomain, a header, a route param — then the scoping is only as trustworthy as the client. That's a leak waiting to happen.
Where the trust should live
Think of it like a building pass. The request is someone saying "I'm here for floor 9." The membership record is the pass that says which floors you're actually allowed on. You check the pass, not the claim.
| Before | After | |
|---|---|---|
| Source of truth | request (subdomain / header) | user's organization membership |
| Who decides the tenant | the client | the server |
| Failure mode | user can land in a tenant they don't belong to | resolution fails closed |
| Testable? | hard — depends on request shape | yes — depends on the user |
The shape of the fix
Resolve the tenant from the authenticated user's organization, in one middleware, before anything tenant-scoped runs:
final class SetTenantContext
{
public function handle(Request $request, Closure $next): Response
{
$org = $request->user()?->currentOrganization();
// No org, no tenant context. Fail closed, never guess.
abort_if($org === null, 403, 'No organization context.');
Tenancy::setCurrent($org->tenant); // server-derived, not request-derived
return $next($request);
}
}
The key line isn't the setCurrent() — it's that the value comes from $request->user(), not from $request. The user is authenticated; the subdomain is not.
request ──> [auth] ──> [SetTenantContext] ──> tenant-scoped routes
│
└── tenant = user's org (NOT the URL/header)
Lock it with a test
A leak like this is exactly the kind of thing that silently regresses. So the fix isn't done until a test would scream if someone reintroduces it:
it('never resolves a tenant the user does not belong to', function () {
$userA = User::factory()->inOrganization($orgA = Organization::factory()->create())->create();
$orgB = Organization::factory()->create();
// Even if the request "asks" for org B, user A must stay in org A.
actingAs($userA)
->withHeader('X-Tenant', $orgB->tenant->getKey())
->get('/dashboards')
->assertOk();
expect(Tenancy::current()->is($orgA->tenant))->toBeTrue();
});
Takeaway
If a value scopes data, it has to come from something the client can't forge. For tenant resolution that means the authenticated user's membership — verified server-side, failing closed when it's missing. Resolve identity from who you are, not from what you ask for.
Top comments (0)