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().
- 01requestIncoming requestsubdomain · header · path · claim
- 02tenancy()Resolve + validate + normalizectx.state.tenant
- 03tenantScope()Partition isolation knobsrateLimit · concurrencyLimit · idempotency · responseCache
- 04handlerTenant-scoped workordersFor(state.tenant)
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.
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.
| Resolver | Source | Notes |
|---|---|---|
tenantFromSubdomain({ baseDomain }) | acme.example.com → acme | PSL-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 header | Spoofable. Only trust behind a proxy that overwrites the header on every inbound request. Always pair with allow. |
tenantFromPathPrefix() | /acme/orders → acme | Reads the segment only (does not rewrite the path); your routes still include the tenant segment. |
tenantFromClaim("org") | ctx.state.auth.credentials.org | For a verified JWT/session claim. The auth middleware that populates it must run before tenancy(). |
(ctx) => string | undefined | anything | Custom resolver: derive the id however you like. |
Options reference
| Option | Type | Default | Description |
|---|---|---|---|
resolve | TenantResolver | TenantResolver[] | (required) | Resolver(s) tried in order; first non-empty wins. |
require | boolean | true | Reject unresolved requests. The secure default: an unresolved request is never served as an ambient “default” tenant. |
allow | string[] | (id, ctx) => boolean | - | Bound the tenant space. Array entries are validated at construction. A disallowed id is rejected with invalidStatus. |
normalize | (raw) => string | undefined | trim + lowercase + strict charset | Validate/canonicalize the raw id. Return undefined to reject. The default accepts 1-63 lowercase alphanumeric characters, with - and _ allowed only inside the id. |
stateKey | string | "tenant" | ctx.state key the resolved id is written to. |
unresolvedStatus | 400 | 401 | 403 | 404 | 400 | Status when require is true and nothing resolved. |
invalidStatus | 400 | 403 | 404 | 404 | Status 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:
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:
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.
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
404by default, indistinguishable from a missing route, so attackers cannot probe for valid tenant names. - Host-spoof safe.
tenantFromSubdomaintreats aHostthat is not under the declaredbaseDomainas unresolved instead of trusting it. - Header resolution is opt-in and spoofable. Only use
tenantFromHeaderbehind a trusted proxy that overwrites the header, and bound it withallow.
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: