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
tenantIdin a JWT you validated at login, after checking membership). Feed it through a custom resolver and settrustResolution: true. - A trusted transport — service-to-service, or a gateway that strips inbound client headers. Wrap the
resolver in
trustedTransport(...)to assert this, thentrustResolution: 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.