Fetching latest headlines…
Resolve the tenant from the user, not the request
NORTH AMERICA
🇺🇸 United StatesJune 30, 2026

Resolve the tenant from the user, not the request

0 views0 likes0 comments
Originally published byDev.to

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.

Comments (0)

Sign in to join the discussion

Be the first to comment!