Search docs

Jump between documentation pages.

Browse docs

Multitenancy

As of 0.42.0 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().

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.route({
  method: "GET",
  path: "/orders",
  operationId: "listOrders",
  responses: { 200: { description: "ok" } },
  handler: ({ 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.

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) => booleanBound 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 only [a-z0-9_-], 1–63 chars.
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";

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

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.

Typing ctx.state.tenant

Augment AppState so the resolved tenant is strongly typed in every handler and hook:

ts
// src/types.d.ts
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 [a-z0-9_-] charset 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";