Skip to content

Search docs

Jump between documentation pages.

Browse docs

Multitenancy

DaloyJS ships tenancy(), a dependency-free, secure-by-default Hooks bundle that resolves the calling tenant once per request, validates and normalizes it, and exposes it on ctx.state.tenant. It is the single source of truth for “who is this request for” so the per-tenant isolation knobs already on the framework (rateLimit, concurrencyLimit, idempotency, responseCache) can all key off the same resolved value via tenantScope().

Resolve once, then isolate
  1. 01requestIncoming requestsubdomain · header · path · claim
  2. 02tenancy()Resolve + validate + normalizectx.state.tenant
  3. 03tenantScope()Partition isolation knobsrateLimit · concurrencyLimit · idempotency · responseCache
  4. 04handlerTenant-scoped workordersFor(state.tenant)
tenancy() resolves the calling tenant once per request and writes it to ctx.state.tenant. Register it first so tenantScope() can key every per-tenant bucket off the same value. If a limiter runs before tenancy(), its key falls back to tenant:unknown.

Quick start

Resolve the tenant from the request subdomain, bound the space with an allowlist, and give every tenant its own rate-limit bucket. Register tenancy() before the isolation middleware so ctx.state.tenant is set by the time they run.

ts
import { App, rateLimit, tenancy, tenantFromSubdomain, tenantScope } from "@daloyjs/core";

const app = new App({
  // Global hook → resolves before any group hook below.
  hooks: tenancy({
    resolve: tenantFromSubdomain({ baseDomain: "example.com" }),
    allow: ["acme", "globex"],
  }),
});

// Each tenant gets an independent 100-req/min bucket.
app.use(rateLimit({ windowMs: 60_000, max: 100, keyGenerator: tenantScope() }));

app.get(
  "/orders",
  {
    operationId: "listOrders",
    responses: { 200: { description: "ok" } },
  },
  ({ state }) => {
    // acme.example.com → state.tenant === "acme"
    const tenant = state.tenant as string;
    return { status: 200 as const, body: { tenant, orders: ordersFor(tenant) } };
  },
);

Resolving the tenant

Pass one resolver to resolve, or an array tried in order until one returns a non-empty value (e.g. prefer a verified JWT claim, fall back to the subdomain). A resolver is just a (ctx) => string | undefined, so you can write your own.

Many sources, one resolved tenant
resolveResolver(s) tried in orderfirst non-empty value wins
subdomaintenantFromSubdomain()acme.example.com to acme
headertenantFromHeader()spoofable, pair with allow
pathtenantFromPathPrefix()/acme/orders to acme
claimtenantFromClaim()verified JWT/session claim
custom(ctx) => string | undefinedderive it however you like
ctx.state.tenantValidated, normalized idstarts/ends alphanumeric, 1 to 63 chars
Pass one resolver or an array tried in order. The first non-empty result is normalized to a conservative tenant-id grammar before it is stored, so a spoofable header value cannot smuggle separators into keys or log lines. tenantFromHeader is opt-in and only safe behind a trusted proxy.
ResolverSourceNotes
tenantFromSubdomain({ baseDomain })acme.example.comacmePSL-aware via subdomains(). A Host not under baseDomain resolves to unresolved (host-spoof safe), never a 500. Recommended for production.
tenantFromHeader("x-tenant-id")request headerSpoofable. Only trust behind a proxy that overwrites the header on every inbound request. Always pair with allow.
tenantFromPathPrefix()/acme/ordersacmeReads the segment only (does not rewrite the path); your routes still include the tenant segment.
tenantFromClaim("org")ctx.state.auth.credentials.orgFor a verified JWT/session claim. The auth middleware that populates it must run before tenancy().
(ctx) => string | undefinedanythingCustom resolver: derive the id however you like.
ts
// Prefer a verified claim, fall back to the subdomain.
tenancy({
  resolve: [tenantFromClaim("org"), tenantFromSubdomain({ baseDomain: "example.com" })],
});

Options reference

OptionTypeDefaultDescription
resolveTenantResolver | TenantResolver[](required)Resolver(s) tried in order; first non-empty wins.
requirebooleantrueReject unresolved requests. The secure default: an unresolved request is never served as an ambient “default” tenant.
allowstring[] | (id, ctx) => boolean-Bound the tenant space. Array entries are validated at construction. A disallowed id is rejected with invalidStatus.
normalize(raw) => string | undefinedtrim + lowercase + strict charsetValidate/canonicalize the raw id. Return undefined to reject. The default accepts 1-63 lowercase alphanumeric characters, with - and _ allowed only inside the id.
stateKeystring"tenant"ctx.state key the resolved id is written to.
unresolvedStatus400 | 401 | 403 | 404400Status when require is true and nothing resolved.
invalidStatus400 | 403 | 404404Status for a resolved-but-disallowed/malformed id. 404 avoids tenant enumeration.

Per-tenant isolation with tenantScope()

tenantScope() returns a (ctx) => string key function that reads ctx.state.tenant and returns a tenant:<id>partition key. Drop it into the isolation knobs so each tenant gets its own bucket / namespace and cannot exhaust, read, or poison another tenant's:

ts
import { tenantScope, rateLimit, concurrencyLimit, idempotency, responseCache } from "@daloyjs/core";

const scope = tenantScope(); // (ctx) => "tenant:<id>"

rateLimit({ windowMs: 60_000, max: 100, keyGenerator: scope });
concurrencyLimit({ maxConcurrent: 20, scope });
idempotency({ scope });   // CWE-524 cross-tenant cached-response defense

// responseCache differs: its keyGenerator REPLACES the whole cache key, and it
// takes ttlSeconds (not ttlMs). Fold the tenant in alongside the path yourself,
// or every URL for a tenant would collide on one entry.
responseCache({
  ttlSeconds: 30,
  keyGenerator: (ctx) => {
    const u = new URL(ctx.request.url);
    return `${scope(ctx)}:${ctx.request.method} ${u.pathname}${u.search}`;
  },
});

Ordering matters. tenancy() resolves in beforeHandle, and so do these consumers. Register tenancy() first, as a global hook (new App({ hooks: tenancy(...) }) or the first app.use(...), so the tenant is populated before any keyGenerator / scope callback runs. If a limiter runs first, its key falls back to tenant:unknown.

Database isolation is yours to wire

This is the boundary worth being explicit about, because people coming from “the framework guarantees isolation with Row-Level Security” expect more than any Node framework can deliver. tenancy() owns tenant identity (a verified, normalized, non-spoofable ctx.state.tenant) and tenantScope() owns per-tenant resource isolation (rate-limit, concurrency, cache, and idempotency buckets). What it deliberately does not do is reach into your database and enforce row isolation, that last inch lives in your data layer. The clean, trustworthy id is exactly what that layer needs:

ts
// (a) Scope every query with the verified id.
const rows = await db.query(
  "SELECT * FROM invoices WHERE tenant_id = $1",
  [ctx.state.tenant],
);

// (b) Or drive Postgres Row-Level Security from a per-request session
// variable, then let your RLS policies do the enforcing.
await db.query("SET app.current_tenant = $1", [ctx.state.tenant]);
// CREATE POLICY tenant_isolation ON invoices
//   USING (tenant_id = current_setting('app.current_tenant'));

Either way, the value reaching your database was already validated and normalized by tenancy(), so a spoofed header or a Host outside your baseDomain can never become a query parameter or an RLS session variable. The resource authorization guide shows how to combine that tenant constraint with user ownership and cross-tenant attack tests.

Typing ctx.state.tenant

Augment AppState so the resolved tenant is strongly typed in every handler and hook. Put the declare module block in a regular .ts module the compiler always checks (for example the file where you register tenancy()), not in a separate .d.ts file: declaration files are exempt from type-checking when skipLibCheck is on (the scaffolded default), so a mistake inside one fails silently.

ts
// src/build-app.ts (same module where tenancy() is registered)
declare module "@daloyjs/core" {
  interface AppState {
    tenant?: string;
  }
}

// Now ctx.state.tenant is string | undefined everywhere.

Security posture

  • Refuse-unresolved by default. With require: true, a request whose tenant cannot be resolved is rejected rather than silently served as a default tenant, the failure mode that leaks one tenant's data to another.
  • Format-validated ids. Resolved ids are normalized to a conservative tenant-id grammar before they are stored or used as a key. A spoofable header value cannot smuggle newlines, :, /, or * into rate-limit keys, cache keys, or log lines (key/log injection, cache poisoning).
  • No enumeration. A resolved-but-unknown tenant is 404 by default, indistinguishable from a missing route, so attackers cannot probe for valid tenant names.
  • Host-spoof safe. tenantFromSubdomain treats a Host that is not under the declared baseDomain as unresolved instead of trusting it.
  • Header resolution is opt-in and spoofable. Only use tenantFromHeader behind a trusted proxy that overwrites the header, and bound it with allow.

Runnable example

examples/multitenancy-demo.ts wires subdomain resolution + an allowlist + per-tenant rate limiting + a per-tenant in-memory store. The Node adapter builds the request URL from the Host header, so you can exercise subdomains locally without DNS:

sh
node --import tsx examples/multitenancy-demo.ts

# acme's data is isolated from globex's:
curl -s localhost:3003/orders -H 'Host: acme.example.com'
curl -s -X POST localhost:3003/orders -H 'Host: acme.example.com' \
  -H 'content-type: application/json' -d '{"item":"widget","total":9.99}'
curl -s localhost:3003/orders -H 'Host: globex.example.com'   # still empty

# Unknown tenant → 404 (no enumeration); no subdomain → 400:
curl -s -o /dev/null -w '%{http_code}\n' localhost:3003/orders -H 'Host: intruder.example.com'
curl -s -o /dev/null -w '%{http_code}\n' localhost:3003/orders -H 'Host: example.com'

Tree-shake-friendly subpath

ts
// Main barrel:
import { tenancy, tenantScope } from "@daloyjs/core";

// Or, to keep your bundle minimal:
import { tenancy, tenantScope } from "@daloyjs/core/tenancy";