TenancyJS
Isolation Strategies

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.

On this page