Single-database multi-tenancy is safe only when tenant scoping is the default and every exception to it is explicit, reviewed, and enforced in more than one layer. This spec defines that model: the schema requirement, the application-level default scope, the registry of sanctioned bypasses, and the database-level backstop that holds when the application forgets.
The governing principle is default-deny: a query with no tenant context returns nothing, not everything. Every rule below exists to make the safe behaviour automatic and the unsafe behaviour loud.
Conformance language follows RFC 2119: MUST / MUST NOT are enforceable requirements, SHOULD / SHOULD NOT are strong recommendations with legitimate exceptions, and MAY marks a genuine choice.
Definition
Schema requirement
Every tenant-bound table carries a non-nullable `tenant_id` foreign key, indexed as the leading column of every composite index that supports a tenant-scoped query. Junction and derived tables carry it directly rather than inferring it through a join — inference is where isolation is lost.
| Column | Type | Constraint | Rationale |
|---|---|---|---|
| tenant_id | uuid / bigint | NOT NULL, FK → tenants(id) | The isolation key; never nullable |
| id | uuid / bigint | PRIMARY KEY | Prefer non-guessable IDs to reduce enumeration risk |
| (tenant_id, …) | index | composite, tenant_id first | Every scoped query hits the index |
Contract
Tenant resolution
The acting tenant is resolved from the request in a fixed priority order, first match wins: a verified token / session claim (the tenant the authenticated principal belongs to), then an explicit `X-Tenant-Id` header (for internal, authenticated service-to-service calls), then the subdomain / host. The order is load-bearing — a token claim is authoritative and a client-supplied header or host MUST NOT override it.
A resolved tenant MUST be validated before it is trusted: the tenant exists and is active (not suspended or deleted), and — where the request carries an authenticated user — that user actually holds a role in that tenant. Resolution and authorization are distinct; resolving a tenant does not by itself grant the caller access to it.
Failure fails closed with a specific status, never a fall-through to an unscoped query: no resolvable tenant on a tenant-scoped route is a 400 (malformed for that route); a tenant that resolves but that the authenticated user has no role in is a 401/403 (authenticated, not authorised for this tenant); a suspended or non-existent tenant is a 403/404 per policy.
| Priority | Source | Trust / failure |
|---|---|---|
| 1 | Verified token / session claim | Authoritative — the tenant the principal belongs to |
| 2 | X-Tenant-Id header | Authenticated internal callers only; never overrides a token claim |
| 3 | Subdomain / host | Public entry; still validated against the tenant registry |
| — | None resolvable | 400 — malformed for a tenant-scoped route |
| — | Resolved, user has no role | 401 / 403 — authenticated, not authorised for this tenant |
Resolution priority is token claim > header > subdomain; a client-supplied header/host MUST NOT override an authenticated token claim, the tenant MUST be validated (exists, active, user has a role), and an unresolved or unauthorised tenant fails closed with a specific status — never an unscoped query.
Isolation policy
Application default scope
A global scope on every tenant-bound model applies the `tenant_id` filter automatically and fails closed: if no tenant is resolved, the scope must produce an impossible predicate, not an absent one. The same trait stamps `tenant_id` on create, so a row can never be written without an owner.
trait BelongsToTenant
{
protected static function bootBelongsToTenant(): void
{
static::addGlobalScope('tenant', function (Builder $q) {
$id = app(TenantContext::class)->id();
// Fail CLOSED: no context => match nothing, never everything.
$q->where($q->getModel()->getTable().'.tenant_id', $id ?? '00000000-0000-0000-0000-000000000000');
});
static::creating(function ($model) {
$model->tenant_id ??= app(TenantContext::class)->requireId();
});
}
}Isolation policy
Where the request scope dies
The tenant is resolved from the request (subdomain, header, verified token) and disappears the moment the request ends. Every asynchronous or out-of-band boundary must re-establish it explicitly. Reading it from `Auth::user()` is prohibited outside the request lifecycle — it is null there.
| Boundary | Failure if unhandled | Carrier |
|---|---|---|
| Queued job | Runs with no / wrong tenant | Tenant ID in constructor, re-bound in handle() |
| Scheduled command / cron | Global run leaks all tenants | Explicit per-tenant loop; no ambient default |
| Model observer | Fires with null auth context | Read tenant from the model, never the request |
| Cache key | One tenant serves another's value | Tenant ID prefixed into every key |
| Broadcast / webhook | Cross-tenant payload | Tenant ID on the event, re-hydrated on use |
Contract
Tenant context across boundaries
Anything that outlives the request MUST carry the tenant explicitly and re-establish it — this is the concrete form of the carriers above. A cache key MUST be prefixed with the tenant, so one tenant can never read another's cached value; the prefix is applied by a wrapper, not left to each call site to remember.
A queued job MUST carry the tenant id in its payload (not a hydrated model, not an ambient default) and re-bind the tenant context at the start of `handle()` before it touches any tenant-scoped data — a worker has no request, so `Auth::user()` and any request-bound tenant are null. A scheduled/cron task iterates tenants explicitly and binds each in turn; it never runs one query across all tenants by omission.
The same rule governs broadcasts, webhooks, and model observers: the tenant travels on the event or is read from the model, and is re-bound before any scoped access. Each of these is a place the default scope silently disappears, which is why re-establishing the tenant is a MUST, not a convention.
// Cache: tenant-prefixed by a wrapper, never by hand.
Cache::remember("t:{$tenantId}:{$key}", $ttl, $cb);
// Queue: tenant in the payload, re-bound before any scoped access.
class SendInvoice implements ShouldQueue, TenantAware
{
public function __construct(public int $tenantId, public int $invoiceId) {}
public function handle(): void
{
app(TenantContext::class)->bind($this->tenantId); // re-establish first
$invoice = Invoice::findOrFail($this->invoiceId); // now correctly scoped
}
}Cache keys MUST be tenant-prefixed by a wrapper; jobs MUST carry the tenant id and re-bind context in handle() before scoped access; scheduled tasks iterate tenants explicitly. Every boundary that outlives the request re-establishes the tenant.
Isolation policy
Sanctioned bypass registry
`withoutGlobalScope('tenant')` is the only permitted way to cross the boundary, and every call site is listed here. An unlisted bypass fails code review. Each entry names the reason and the compensating control that keeps it safe.
A bypass applies to a tenant-scoped model — one that has a `tenant_id` — read deliberately across tenants. A model that is inherently un-scoped (shared reference data, see Cross-tenant and shared data) has no tenant boundary to cross, so it is not a bypass and needs no registry entry.
| Call site | Reason | Compensating control |
|---|---|---|
| Platform admin console | Cross-tenant support view | Admin-only middleware + audit log per read |
| POPIA/erasure checks | Detect records across tenants | Read-only; returns existence, not data |
| Billing rollup job | Aggregate across tenants | Writes to a separate reporting schema only |
Definition
Cross-tenant and shared data
Not all data is tenant-owned. Shared reference data — country codes, currency lists, a global product catalogue, system roles — is not tenant-scoped and MUST NOT carry a `tenant_id`. Forcing a tenant key onto genuinely-shared data either duplicates it per tenant or invites a nullable `tenant_id`, and a nullable tenant key is precisely the hole this model exists to close. Shared data lives in its own tables (or a separate schema), explicitly outside the tenant-scoping and RLS machinery, and read-only to tenants.
A relationship that genuinely spans tenants — a marketplace order referencing another tenant's catalogue item, a parent/child tenant hierarchy — is modelled explicitly, not by relaxing isolation. The join row carries both tenant ids and is itself access-controlled, so crossing the boundary is a named, audited operation (a bypass-registry entry), never an ambient consequence of a foreign key. If two tenants must share mutable data, that shared surface is its own bounded context with its own rules, not a leak in either tenant's scope.
This is distinct from the sanctioned bypass registry, and the test is the column. Shared data is inherently un-scoped — it has no `tenant_id` by design and needs no registry entry. A bypass is a tenant-scoped model (one that has a `tenant_id`) deliberately read across tenants. If a model has a `tenant_id`, crossing it is a bypass; if it has none by design, it is shared data.
Genuinely-shared reference data is NOT tenant-scoped and MUST NOT carry a nullable tenant_id — it lives in separate, read-only tables outside the RLS machinery, and is distinct from a bypass (which crosses a tenant-scoped model deliberately). The test is the column: has tenant_id → bypass; no tenant_id by design → shared data.
Isolation policy
Database backstop (RLS)
Application scoping is necessary but not sufficient; a forgotten `where` clause must still fail closed. Postgres Row-Level Security enforces the same predicate at the engine, keyed on a session GUC set per transaction. The setting is applied inside the transaction that uses it and reset on connection release, so a pooled connection never carries one tenant's context into another's checkout.
The role requirements are specific: the application MUST connect as a role that is not a superuser and is not the table owner — or the tables use `FORCE ROW LEVEL SECURITY`, which applies the policy even to the owner — because both superusers and table owners bypass RLS by default. Policies are written per command: a `USING` clause filters the rows a SELECT / UPDATE / DELETE can see, and a `WITH CHECK` clause constrains what an INSERT / UPDATE may write, so a tenant can neither read nor write across the boundary.
RLS has real limits to design around. It does not govern `TRUNCATE` (which ignores row policies entirely — revoke it from the app role); it is bypassed by superusers and, without FORCE, by the table owner; and some referential-integrity and bulk paths run with elevated privileges. Treat RLS as the backstop that catches a forgotten application filter, not the sole control — the application scope, the non-superuser role, revoked TRUNCATE, and the per-transaction GUC all have to hold together.
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY; -- applies even to the table owner
CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = current_setting('app.current_tenant', true)::uuid) -- reads
WITH CHECK (tenant_id = current_setting('app.current_tenant', true)::uuid); -- writes
REVOKE TRUNCATE ON invoices FROM app_role; -- TRUNCATE ignores row policies
-- Per transaction, the app sets the tenant and asserts it took effect:
-- BEGIN;
-- SET LOCAL app.current_tenant = '...';
-- -- current_setting(...) NULL => matches no rows => fails closed
-- COMMIT;The app role MUST NOT be a superuser or the table owner (or the table uses FORCE ROW LEVEL SECURITY); policies use USING for reads and WITH CHECK for writes; TRUNCATE ignores RLS and MUST be revoked. RLS is the backstop, not the sole control.
Definition
Performance and indexing
`tenant_id` MUST be the leading column of every index that supports a tenant-scoped query, because every such query filters on it first: a composite `(tenant_id, …)` index turns the tenant filter into an index seek and keeps each tenant's working set contiguous. An index that omits `tenant_id`, or puts it second, forces the planner to scan across tenants and then filter — slow, and a noisy-neighbour risk where one large tenant degrades queries for everyone.
The RLS policy adds its predicate to every query, ANDed with the query's own filters. Its cost is negligible when the predicate is `tenant_id = current_setting(...)` and that column leads an index — the same index the application filter already uses. It becomes expensive only when the policy references a subquery or a function the planner can't inline, so the policy is kept to a simple, indexable equality on the tenant column, never a join or a lookup.
For very large tenants or hard isolation guarantees, list- or hash-partition the biggest tables by `tenant_id` so each tenant's rows live in their own partition — the planner prunes to one partition, and a partition is a natural unit for archival or a per-tenant restore. Partitioning is an optimisation on top of the row-level model, not a replacement for it; the tenant_id column, the scope, and the RLS policy all remain.
| Concern | Rule |
|---|---|
| Index shape | tenant_id is the LEADING column of every scoped index |
| RLS predicate | Simple indexable equality on tenant_id — no subquery / function |
| Noisy neighbour | Composite index keeps each tenant's working set contiguous |
| Very large tenants | Partition big tables by tenant_id; the planner prunes to one partition |
Invariants this spec guarantees
- A query with no resolved tenant context MUST return zero rows, not all rows — the application scope and the RLS policy both fail closed.
- A row MUST NOT be written without a tenant_id; the value is stamped on create, never supplied by the client.
- Tenant resolution follows a fixed priority (token claim > header > subdomain); a client-supplied header/host MUST NOT override a token claim, the tenant MUST be validated (exists, active, user has a role), and an unresolved or unauthorised tenant fails closed with a specific status.
- Every boundary that outlives the request MUST carry the tenant explicitly and re-establish it — cache keys tenant-prefixed, jobs carrying the tenant id and re-binding in handle(), cron iterating tenants.
- Genuinely-shared reference data is NOT tenant-scoped and MUST NOT carry a nullable tenant_id; a cross-tenant relationship is modelled explicitly as a named, audited bypass.
- Every cross-tenant read MUST be an entry in the bypass registry, admin-gated, and audit-logged.
- The RLS backstop holds only when the app connects as a non-superuser, non-owner (or FORCE) role with the tenant GUC set and TRUNCATE revoked; a superuser, an unset GUC, or TRUNCATE is outside it.
- tenant_id MUST be the leading column of every index supporting a scoped query, and the RLS predicate is a simple indexable equality.
Revision history: revised 24 August 2026 for consistency (RFC 2119; RLS backstop stated with its preconditions). Revised 25 August 2026 to add tenant resolution (priority token > header > subdomain, validation, fail-closed statuses), tenant context across boundaries (cache prefixing, job payload, worker re-bind), cross-tenant & shared data (no nullable tenant_id; explicitly-modelled bypass), expanded RLS implementation (role requirements, USING / WITH CHECK, per-transaction GUC, TRUNCATE and superuser limits), and performance & indexing (tenant_id leading column, RLS predicate cost, partitioning).