Single database (row-level)
Shared tables keyed by tenant_id, enforced by forced Postgres RLS or query-scoping - the lightest strategy.
Row-level is the simplest and most common strategy: all tenants share the same tables, and every
row carries a tenant_id. TenancyJS makes sure every query inside a tenant scope is filtered to that
tenant, and that writes are stamped with the right tenant_id.
It's the right default when your tenants are many and lightweight, and you don't need physical separation.
Available on all three databases: PostgreSQL (forced RLS), MySQL (protected query-scoping - 🧪 experimental), and MongoDB (Mongoose facade). MySQL and MongoDB also support database-per-tenant; they do not have a distinct Postgres-style schema strategy. See the capability matrix for the per-database posture.
How it's enforced
There are two enforcement layers, depending on your database:
- Forced Postgres RLS (Knex, Lucid, TypeORM, Sequelize, Drizzle) - the adapter sets the tenant on the session, and a row-level security policy on the table makes the database itself reject rows from other tenants. This holds even under raw SQL.
- Query-scoping (Prisma, TypeORM/Sequelize/Drizzle on MySQL, Mongoose) - the adapter injects the tenant filter into every query and the tenant field into every write through a whitelist facade.
The RLS-backed adapters (Knex, Lucid, TypeORM, Sequelize, Drizzle on PostgreSQL) use both layers
(facade + forced RLS), so a bug in one is caught by the other. Under forced RLS these adapters also
allow unrestricted() raw SQL: the query runs under a non-BYPASSRLS role, so the validated policy
binds it to the current tenant (in central mode unrestricted() is still refused). Prisma's
extension path is facade-only - it has no RLS layer on either PostgreSQL or MySQL, so there the
adapter facade is the entire guarantee (like MySQL and Mongoose above). Prisma also has an RLS-backed
path (createPrismaRowLevelTenancy, a run-scoped interactive transaction that SET LOCALs the tenant
GUC) that adds the same PostgreSQL database backstop; it needs a driver adapter (@prisma/adapter-pg)
and forced RLS. Keep all tenant access going through the scoped client.
Setup
Register your tenant-scoped models/tables with the adapter, then run inside a tenant scope. The exact call depends on your ORM - see the adapter guide:
await manager.runWithTenant({ id: "acme" }, async () => {
await db.order.findMany(); // WHERE tenant_id = 'acme', injected for you
});The RLS backstop
For the RLS-backed SQL adapters (Knex, Lucid, TypeORM, Sequelize, Drizzle) on PostgreSQL, each tenant
table needs a row-level security policy. The adapter validates this contract at startup and refuses to
run (validate() fails) until it's in place - so this SQL isn't optional, it's the backstop that makes
a facade bug non-fatal.
TenancyJS sets two transaction-local settings on every scoped query - tenancyjs.tenant_id (the current
tenant) and tenancyjs.is_central ('true' only in the central scope). Your policy reads them:
-- A non-privileged runtime role - must NOT be able to bypass RLS, and must NOT own the tables.
create role app_runtime login nosuperuser nobypassrls;
-- Per tenant table:
alter table posts enable row level security;
alter table posts force row level security; -- FORCE so even the table owner is subject to it
create policy posts_tenant_isolation on posts
using (
current_setting('tenancyjs.is_central', true) = 'true'
or tenant_id = nullif(current_setting('tenancyjs.tenant_id', true), '')
)
with check (
current_setting('tenancyjs.is_central', true) = 'true'
or tenant_id = nullif(current_setting('tenancyjs.tenant_id', true), '')
);
-- Grant the runtime role table access (it must not be the owner):
grant select, insert, update, delete on posts to app_runtime;The WITH CHECK clause blocks writing a row under the wrong tenant, and FORCE ensures the owner can't
sidestep it. The policy name matters: the adapter validates a policy named
<table>_tenant_isolation by default (hence posts_tenant_isolation above) - if you name it something
else, pass that name via the adapter's policyName option, or validate() fails with a policy-invalid
error.
Two roles, two connections. RLS only bites when the app connects as a role that cannot bypass it.
So run the DDL above (create table, policy, grants) as the table owner (a privileged/admin
connection), and connect your app as app_runtime (the nobypassrls, non-owner role). If the app
connects as the owner or a superuser, RLS silently no-ops and you're back to facade-only. A common
setup: a separate admin DB connection for migrations, and the default connection as app_runtime.
The runtime role must be nobypassrls, not a superuser, and not the owner of the tenant tables -
a role that can bypass RLS makes the backstop meaningless. Prisma's extension path is facade-only (no
RLS layer); its RLS-backed path (createPrismaRowLevelTenancy) adds the PostgreSQL backstop. Mongoose
is facade-only because MongoDB has no RLS - for those facade-only paths, the adapter facade is the
entire guarantee.
Databases
Row-level runs on PostgreSQL (forced RLS), MySQL (query-scoping, 🧪 experimental), and MongoDB (Mongoose facade).
When to reach for more
If tenants need physical separation - compliance, per-tenant backups, noisy-neighbour isolation - consider schema-per-tenant or database-per-tenant.
Choosing a strategy
Row-level, schema-per-tenant, or database-per-tenant - the tradeoffs in isolation, noisy-neighbor risk, connection scaling, compliance, and query freedom, and how to decide.
Schema per tenant
One Postgres schema per tenant, selected per transaction via search_path - with optional database-enforced isolation.