# 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()`.

**Diagram: Resolve once, then isolate**

1. **Incoming request** (request) - subdomain · header · path · claim
2. **Resolve + validate + normalize** (tenancy()) - ctx.state.tenant
3. **Partition isolation knobs** (tenantScope()) - rateLimit · concurrencyLimit · idempotency · responseCache
4. **Tenant-scoped work** (handler) - ordersFor(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 a `(ctx) => string | undefined`, so you can write your own.

**Diagram: Many sources, one resolved tenant**

- **Resolver(s) tried in order** (source, resolve) - first non-empty value wins
- **tenantFromSubdomain()** (subdomain) - acme.example.com to acme
- **tenantFromHeader()** (header) - spoofable, pair with allow
- **tenantFromPathPrefix()** (path) - /acme/orders to acme
- **tenantFromClaim()** (claim) - verified JWT/session claim
- **(ctx) => string | undefined** (custom) - derive it however you like
- **Validated, normalized id** (converge, ctx.state.tenant) - starts/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.

| 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. |

```ts
// Prefer a verified claim, fall back to the subdomain.
tenancy({
  resolve: [tenantFromClaim("org"), tenantFromSubdomain({ baseDomain: "example.com" })],
});
```

## 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:

```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 needs NO wiring: it folds the resolved tenant into its cache
// key on its own, and does so around any custom keyGenerator, so a generator
// cannot accidentally widen the partition.
responseCache({ ttlSeconds: 30 });
```

**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`. For `responseCache()` specifically the stakes are high enough that the order is *enforced*: a cache mounted ahead of `tenancy()` [refuses to boot in production](/docs/security/boot-guards#7-responsecache-mounted-ahead-of-tenancy) rather than serve one tenant's response to another.

## 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](/docs/security/resource-authorization) 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()`). A separate `.d.ts` file is the wrong place. 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).
- Unknown tenants are not enumerable. 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";
```

---

Source: https://daloyjs.dev/docs/multitenancy