TenancyJS
Guides

Resolving tenants

Turn an incoming request into a tenant - from subdomains, headers, paths, or your store.

Every integration takes a resolver: a function that turns a request into a tenant. This guide covers the common patterns and how to fail closed correctly.

The shape

A resolver returns the tenant (or enough to identify it). The simplest form reads an identifier straight off the request:

resolver: (req) => ({ id: req.subdomains.at(-1) ?? "" });

If it can't produce a valid tenant, the integration responds with the right status and your handler never runs.

Common sources

// acme.example.com → "acme"
resolver: (req) => ({ id: req.subdomains.at(-1) ?? "" });
resolver: (req) => ({ id: req.header("x-tenant") ?? "" });
// /t/acme/orders → "acme"
resolver: (req) => ({ id: req.path.split("/")[2] ?? "" });
// look the domain up in your store
resolver: async (req) => {
  const tenant = await store.findByDomain(req.hostname);
  return tenant ?? null; // null → fails closed (404)
};

Looking up and validating

For anything beyond reading a string, resolve against your store - and let unknown or suspended tenants fail closed:

resolver: async (req) => {
  const slug = req.subdomains.at(-1);
  if (!slug) return null; // 400 - no identifier
  const tenant = await store.find(slug);
  if (!tenant || tenant.status === "suspended") return null; // 404
  return tenant;
};

Resolving is not authorizing

Resolving a tenant proves it exists — never that the user may act as it. x-tenant-id is a value the client sets; any logged-in user can send another tenant's id. Without a membership check, they get scoped to that tenant and read its data. RLS does not help — it scopes to whatever tenant you resolved. This is the single most important thing to get right.

So resolution has two parts: identify the tenant, then authorize that this principal belongs to it. TenantResolutionChain makes the second part mandatory — it refuses to construct unless you either pass an authorize hook or explicitly opt out with trustResolution. You cannot ship the spoofable default by accident.

import { HeaderTenantResolver, TenantResolutionChain } from "tenancyjs-identifiers";

const chain = new TenantResolutionChain<Tenant>({
  resolvers: [new HeaderTenantResolver({ headerName: "x-tenant-id" })],
  store: {
    // Look the identifier up in YOUR store and return the matches.
    async find(identifier) {
      const tenant = await lookupTenant(identifier.value);
      return tenant ? [{ tenant, status: "active" }] : [];
    },
  },
  // Required: does THIS authenticated user belong to the resolved tenant?
  authorize: ({ tenant, principal }) =>
    (principal as User).teamIds.includes(tenant.id),
});

const outcome = await chain.resolve(
  { host: req.host, headers: req.headers },
  { principal: req.user }, // the authenticated user, from your auth middleware
);
if (outcome.status === "resolved") {
  // outcome.tenant — hand it to manager.runWithTenant(...)
}

The integrations extract the principal for you — pass principal: (req) => req.user (Express/Nest), (ctx) => ctx.auth.user (Adonis), or a session thunk (Next.js) — and run the middleware after your authentication.

When can you skip the membership check?

Only when the identifier is not client-forgeable. Two cases:

  • A signed claim you already verified (a tenantId in a JWT you validated at login, after checking membership). Feed it through a custom resolver and set trustResolution: true.
  • A trusted transport — service-to-service, or a gateway that strips inbound client headers. Wrap the resolver in trustedTransport(...) to assert this, then trustResolution: true.

You cannot combine trustResolution with a raw HeaderTenantResolver (or any spoofable resolver) — trusting a value the client set is the hole itself, so the chain rejects it. A spoofable resolver must go through authorize.

Failure semantics

Outcomes map to a status consistently - 400 for no-identifier/invalid, 404 for not-found/suspended/forbidden (all deliberately indistinguishable, so a caller can't enumerate tenants or probe membership), 500 for ambiguous (more than one match) - so every framework behaves the same way. The Express/Next/Nest/Adonis integrations apply this for you.

This resolution store - find(identifier) → { tenant, status }[] - is not the same interface as the registry store the CLI uses (whose find(id) returns one tenant or null). Same idea, different shapes - don't reuse one object for both. And a resolver must never guess a tenant or fall back to a default; no match is the safe, fail-closed outcome.

On this page