DEV Community

Cover image for Resolve the tenant from the user, not the request
Nasrul Hazim Bin Mohamad
Nasrul Hazim Bin Mohamad

Posted on

Resolve the tenant from the user, not the request

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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)