How to buildOctober 11, 202614 min read
Software ArchitectureStep 9 of 10

Multi-tenant SaaS: isolating tenant data

In short

A multi-tenant SaaS serves many customer tenants from one system, and the core requirement is that one tenant's data is never visible to another. The most economical model is a shared schema with a tenant_id column in every table; in PostgreSQL it is backed by Row-Level Security, where a policy on tenant_id filters rows inside the database even if an application query forgets the condition. Separate schemas or databases per tenant give stronger isolation at an operational cost, and RLS is bypassed by superusers, BYPASSRLS roles and the table owner without FORCE.

Deep diveLevel: AdvancedMulti-Tenant SaaS

After this you will be able to

  • compare a shared schema with tenant_id, schema per tenant and database per tenant on isolation, cost, migrations and noisy neighbours
  • enable PostgreSQL Row-Level Security with USING and WITH CHECK policies, FORCE and a dedicated application role without BYPASSRLS
  • pass the tenant into queries with set_config(..., true) so that it never leaks between requests on a pooled connection
  • write cross-tenant leak tests and a check that no table with tenant_id is left without RLS

Verified with

PostgreSQL 17 (17.11, postgres:17-alpine)Node.js 24 LTS (24.18)node-postgres (pg) 8.23node:test

Checked against docs: October 11, 2026

Contents
  1. 01What multi-tenancy is and where a tenant ends
  2. 02Three isolation models and their cost
  3. 03tenant_id everywhere: schema, keys and indexes
  4. 04Row-Level Security in PostgreSQL: USING and WITH CHECK policies
  5. 05Passing the tenant into queries: from HTTP to SET LOCAL
  6. 06Pitfalls: who bypasses RLS and how
  7. 07Isolation tests
  8. 08Noisy neighbours, limits and billing: an overview

What multi-tenancy is and where a tenant ends

A tenant is a SaaS customer whose data must be kept apart from everyone else's. In B2B it is usually an organisation: Microsoft's guide to tenancy models suggests mapping tenants to customer organisations and treating all of their employees as users within one tenant. The running example here is an online booking service for salons: each salon is a tenant, its admins and masters are users, and the salon's clients and bookings are tenant data.

Tenant isolation is not the same as authorization. Authorization asks whether this user may perform this action; isolation asks whether anything in this tenant's request can touch another tenant's rows at all. In a shared database, one forgotten WHERE tenant_id = … in a report leaks every salon's data, so isolation is built not to depend on the care of whoever writes each query.

  • Tenants and deployments are different things: Microsoft distinguishes logical tenants from deployments (also called stamps). One set of infrastructure can serve many tenants, and a large customer can get its own
  • The server determines the tenant from verified data — a signed token, the user's membership in an organisation — not from whatever the client sent
  • Every data operation runs in the context of exactly one tenant; operations across all tenants (analytics, billing) are a separate, explicit path

Three isolation models and their cost

AWS's SaaS Lens describes three patterns: silo, where a tenant has dedicated resources (its own stack or database); pool, where tenants share resources; and bridge, a mix where part of the system is dedicated and part is shared. Microsoft suggests treating isolation as a spectrum from fully shared to fully isolated and choosing a level per tier. For the data tier in PostgreSQL, there are three typical points on that spectrum:

Isolation

Shared schema + tenant_id (pool)
Logical: tenant_id and RLS policies
Schema per tenant
Separate objects; search_path and privileges separate access
Database per tenant (silo)
Physically separate databases or servers

Cost per tenant

Shared schema + tenant_id (pool)
Minimal: a new tenant is one row
Schema per tenant
Grows with the number of schemas
Database per tenant (silo)
Highest: a separate database, backups, monitoring

Migrations

Shared schema + tenant_id (pool)
One for everyone
Schema per tenant
Repeated for every schema
Database per tenant (silo)
Repeated for every database, needs orchestration

Cross-tenant reporting

Shared schema + tenant_id (pool)
A regular query (with a separate role)
Schema per tenant
Union across schemas
Database per tenant (silo)
Collecting data from many databases

Noisy neighbour

Shared schema + tenant_id (pool)
Share CPU, memory and I/O
Schema per tenant
Share the same server
Database per tenant (silo)
Isolated if databases live on different servers

Back up and restore one tenant

Shared schema + tenant_id (pool)
Selectively, by tenant_id
Schema per tenant
Dump one schema
Database per tenant (silo)
Dump one database

Data residency requirements

Shared schema + tenant_id (pool)
Hard: all data in one place
Schema per tenant
Hard
Database per tenant (silo)
Easier: the database lives where the customer requires

Microsoft lists the risks of a fully shared model: data must be separated carefully, a large tenant with heavy queries affects the rest, and every change hits all customers at once. The risks of a dedicated model are low cost efficiency (100 tenants cost roughly 100 times the infrastructure) and laborious maintenance of many copies. Schema per tenant sits in between; the engineering trade-off is that every migration runs once per schema, and with thousands of schemas the catalog and deployment time grow.

Shared schema fits when

  • There are many small tenants with similar load
  • All customers share one data model
  • New tenants must be onboarded quickly
  • No customer requires physically separate data

A separate database fits when

  • There are few large customers with regulatory or contractual requirements
  • A customer needs its own maintenance windows, version or region
  • One tenant's load is comparable to the whole system
  • The customer is willing to pay for dedicated infrastructure

In our experience, a sensible start for a SaaS aimed at small and medium businesses is a shared schema with RLS, designed so that a large tenant can later move to its own database (bridge in AWS terms, vertical partitioning in Microsoft's). That move is possible only if tenant_id is in every table from day one — which is what we build next.

tenant_id everywhere: schema, keys and indexes

The schema below is the foundation of this deep dive. A superuser runs it once, as a migration. Tables are owned by app_owner, a role that cannot log in; the application connects as app_user, which is neither a superuser nor has BYPASSRLS.

sql/01-schema.sql
-- Run as a superuser once (migration). The app never connects as owner or superuser.
CREATE ROLE app_owner NOLOGIN;
CREATE ROLE app_user LOGIN PASSWORD 'change-me' NOSUPERUSER NOBYPASSRLS;

CREATE SCHEMA app AUTHORIZATION app_owner;
GRANT USAGE ON SCHEMA app TO app_user;
SET ROLE app_owner;

-- Tenant of the current transaction; NULL when not set, so policies match nothing.
CREATE FUNCTION app.current_tenant() RETURNS uuid
  LANGUAGE sql STABLE
  AS $$ SELECT nullif(current_setting('app.tenant_id', true), '')::uuid $$;

CREATE TABLE app.tenants (
  id   uuid PRIMARY KEY,
  name text NOT NULL,
  plan text NOT NULL DEFAULT 'basic'
);

CREATE TABLE app.clients (
  tenant_id uuid NOT NULL DEFAULT app.current_tenant() REFERENCES app.tenants (id),
  id        uuid NOT NULL DEFAULT gen_random_uuid(),
  name      text NOT NULL,
  PRIMARY KEY (tenant_id, id)
);

CREATE TABLE app.bookings (
  tenant_id uuid NOT NULL DEFAULT app.current_tenant(),
  id        uuid NOT NULL DEFAULT gen_random_uuid(),
  client_id uuid NOT NULL,
  starts_at timestamptz NOT NULL,
  PRIMARY KEY (tenant_id, id),
  -- composite key: a booking cannot point at another tenant's client
  FOREIGN KEY (tenant_id, client_id) REFERENCES app.clients (tenant_id, id)
);
CREATE INDEX bookings_tenant_starts_idx ON app.bookings (tenant_id, starts_at);

ALTER TABLE app.tenants  ENABLE ROW LEVEL SECURITY;
ALTER TABLE app.clients  ENABLE ROW LEVEL SECURITY;
ALTER TABLE app.bookings ENABLE ROW LEVEL SECURITY;
-- FORCE: the table owner is subject to the policies too
ALTER TABLE app.tenants  FORCE ROW LEVEL SECURITY;
ALTER TABLE app.clients  FORCE ROW LEVEL SECURITY;
ALTER TABLE app.bookings FORCE ROW LEVEL SECURITY;

CREATE POLICY tenant_self ON app.tenants
  USING (id = app.current_tenant());
CREATE POLICY tenant_isolation ON app.clients
  USING (tenant_id = app.current_tenant())
  WITH CHECK (tenant_id = app.current_tenant());
CREATE POLICY tenant_isolation ON app.bookings
  USING (tenant_id = app.current_tenant())
  WITH CHECK (tenant_id = app.current_tenant());

GRANT SELECT ON app.tenants TO app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON app.clients, app.bookings TO app_user;
RESET ROLE;
The change-me password is a placeholder for the local test bench; a real system takes it from a secrets manager
  1. tenant_id in every table

    Child tables too: bookings does not derive its tenant by joining clients, it stores it. Otherwise the policy would need a subquery, and the PostgreSQL docs warn that policies consulting other rows or tables are slower and can create race conditions that leak data.

  2. Composite primary key

    PRIMARY KEY (tenant_id, id) makes tenant_id the leading index column, so queries within a tenant use it naturally.

  3. Composite foreign key

    FOREIGN KEY (tenant_id, client_id) guarantees in the database that a booking can reference only a client of its own salon. A plain key on client_id would allow pointing at another tenant's client.

  4. Indexes start with tenant_id

    Almost every query filters by tenant, so (tenant_id, starts_at) is more useful than (starts_at). Unique constraints include tenant_id as well: a phone number is unique within a salon, not across the system.

  5. A default value

    DEFAULT app.current_tenant() fills in the tenant of the current transaction, so insert code does not pass tenant_id by hand.

app.current_tenant() is a STABLE SQL function, so the planner inlines its body into the policy condition. Let us check that at volume: add 200 tenants with 5 clients each and 200 bookings per client, and look at the plan as the application role.

sql/05-explain.sql
-- Run as the superuser: 200 more tenants, 5 clients each, 200 bookings per client
INSERT INTO app.tenants (id, name)
  SELECT gen_random_uuid(), 'Tenant ' || g FROM generate_series(1, 200) g;
INSERT INTO app.clients (tenant_id, name)
  SELECT t.id, 'Client ' || g FROM app.tenants t, generate_series(1, 5) g;
INSERT INTO app.bookings (tenant_id, client_id, starts_at)
  SELECT c.tenant_id, c.id, '2026-11-01 09:00+05'::timestamptz + g * interval '1 hour'
    FROM app.clients c, generate_series(1, 200) g;
ANALYZE app.tenants, app.clients, app.bookings;

SET ROLE app_user;
BEGIN;
SELECT set_config('app.tenant_id', '11111111-1111-4111-8111-111111111111', true);
EXPLAIN (COSTS OFF)
  SELECT * FROM app.bookings
   WHERE starts_at >= '2026-11-02' AND starts_at < '2026-11-03'
   ORDER BY starts_at;
COMMIT;
RESET ROLE;
terminal
$ docker exec kb-tenants sh -c "psql -U postgres -d saas -X -e -f /sql/05-explain.sql 2>&1"
…
EXPLAIN (COSTS OFF)
  SELECT * FROM app.bookings
   WHERE starts_at >= '2026-11-02' AND starts_at < '2026-11-03'
   ORDER BY starts_at;
                                                                                                                         QUERY PLAN                                                                                                                          
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
 Sort
   Sort Key: starts_at
   ->  Bitmap Heap Scan on bookings
         Recheck Cond: ((tenant_id = (NULLIF(current_setting('app.tenant_id'::text, true), ''::text))::uuid) AND (starts_at >= '2026-11-02 00:00:00+00'::timestamp with time zone) AND (starts_at < '2026-11-03 00:00:00+00'::timestamp with time zone))
         ->  Bitmap Index Scan on bookings_tenant_starts_idx
               Index Cond: ((tenant_id = (NULLIF(current_setting('app.tenant_id'::text, true), ''::text))::uuid) AND (starts_at >= '2026-11-02 00:00:00+00'::timestamp with time zone) AND (starts_at < '2026-11-03 00:00:00+00'::timestamp with time zone))
(6 rows)
Output excerpt: the policy condition became part of the Index Cond on (tenant_id, starts_at)

Row-Level Security in PostgreSQL: USING and WITH CHECK policies

Row-Level Security (RLS) is enabled with ALTER TABLE … ENABLE ROW LEVEL SECURITY. From then on, according to the PostgreSQL docs, all normal access for selecting or modifying rows must be allowed by a policy; if no policy exists, a default-deny policy applies and no rows are visible or modifiable. Whole-table operations such as TRUNCATE are not subject to RLS.

USING

What it does
Which existing rows are visible and available to UPDATE and DELETE; false or NULL hides the row without an error
In our schema
tenant_id = app.current_tenant()

WITH CHECK

What it does
What a new row must satisfy after INSERT or UPDATE; false or NULL raises an error
In our schema
The same condition: no writing into another tenant

Permissive (default)

What it does
Policies are combined with OR
In our schema
One policy per table

Restrictive

What it does
Combined with AND on top of permissive ones
In our schema
Not needed here; useful for, say, read-only access for blocked tenants

FORCE ROW LEVEL SECURITY

What it does
Policies apply to the table owner as well
In our schema
Enabled on all three tables

When app.tenant_id is not set, current_setting('app.tenant_id', true) returns NULL (the second argument true means "don't fail if the parameter is missing"), and after the transaction ends it returns an empty string, which nullif turns back into NULL. A comparison with NULL is never true, so without a tenant the policy lets no rows through: the system is closed by default. Start the bench and run the scenario as the application:

start.sh
docker run -d --rm --name kb-tenants -e POSTGRES_PASSWORD=test \
  -p 127.0.0.1:55439:5432 -v "$PWD/sql:/sql:ro" postgres:17-alpine
sleep 5
docker exec kb-tenants psql -U postgres -X -qc "CREATE DATABASE saas"
docker exec kb-tenants psql -U postgres -d saas -X -v ON_ERROR_STOP=1 -f /sql/01-schema.sql
docker exec kb-tenants psql -U postgres -d saas -X -v ON_ERROR_STOP=1 -f /sql/02-seed.sql
The Docker bench: run bash start.sh from the project folder (in Git Bash on Windows, MSYS_NO_PATHCONV=1 bash start.sh). sh -c "… 2>&1" in the commands below merges stdout and stderr inside the container so errors stay in place
sql/02-seed.sql
-- Test data, loaded by the superuser (it bypasses RLS).
INSERT INTO app.tenants (id, name) VALUES
  ('11111111-1111-4111-8111-111111111111', 'Lavender Salon'),
  ('22222222-2222-4222-8222-222222222222', 'Mint Barbershop');

INSERT INTO app.clients (tenant_id, id, name) VALUES
  ('11111111-1111-4111-8111-111111111111', 'aaaaaaaa-0000-4000-8000-000000000001', 'Aigerim'),
  ('11111111-1111-4111-8111-111111111111', 'aaaaaaaa-0000-4000-8000-000000000002', 'Dana'),
  ('22222222-2222-4222-8222-222222222222', 'bbbbbbbb-0000-4000-8000-000000000001', 'Timur');

INSERT INTO app.bookings (tenant_id, client_id, starts_at) VALUES
  ('11111111-1111-4111-8111-111111111111', 'aaaaaaaa-0000-4000-8000-000000000001', '2026-11-02 10:00+05'),
  ('22222222-2222-4222-8222-222222222222', 'bbbbbbbb-0000-4000-8000-000000000001', '2026-11-02 11:00+05');
sql/03-isolation.sql
-- Run as app_user: psql -U app_user -d saas -f 03-isolation.sql
\set A '11111111-1111-4111-8111-111111111111'
\set B '22222222-2222-4222-8222-222222222222'

-- 1. No tenant in the transaction: nothing is visible
SELECT count(*) AS clients_without_tenant FROM app.clients;

-- 2. Tenant A sees only its own rows, even when asking for B explicitly
BEGIN;
SELECT set_config('app.tenant_id', :'A', true);
SELECT t.name AS tenant, c.name AS client
  FROM app.clients c JOIN app.tenants t ON t.id = c.tenant_id
  ORDER BY c.name;
SELECT count(*) AS rows_of_b FROM app.clients WHERE tenant_id = :'B';
UPDATE app.clients SET name = 'Hacked' WHERE tenant_id = :'B';
DELETE FROM app.bookings WHERE tenant_id = :'B';
COMMIT;

-- 3. Writing into another tenant is rejected by WITH CHECK
BEGIN;
SELECT set_config('app.tenant_id', :'A', true);
INSERT INTO app.clients (tenant_id, name) VALUES (:'B', 'Mallory');
ROLLBACK;

-- 4. tenant_id defaults to the current tenant; a booking cannot point at B's client
BEGIN;
SELECT set_config('app.tenant_id', :'A', true);
INSERT INTO app.clients (name) VALUES ('Saule') RETURNING tenant_id, name;
INSERT INTO app.bookings (client_id, starts_at)
  VALUES ('bbbbbbbb-0000-4000-8000-000000000001', '2026-11-03 12:00+05');
ROLLBACK;

-- 5. The setting ends with the transaction
SELECT current_setting('app.tenant_id', true) AS after_commit,
       (SELECT count(*) FROM app.clients) AS visible_clients;
terminal
$ docker exec kb-tenants sh -c "psql -U app_user -d saas -X -e -f /sql/03-isolation.sql 2>&1"
SELECT count(*) AS clients_without_tenant FROM app.clients;
 clients_without_tenant 
------------------------
                      0
(1 row)

BEGIN;
BEGIN
SELECT set_config('app.tenant_id', '11111111-1111-4111-8111-111111111111', true);
              set_config              
--------------------------------------
 11111111-1111-4111-8111-111111111111
(1 row)

SELECT t.name AS tenant, c.name AS client
  FROM app.clients c JOIN app.tenants t ON t.id = c.tenant_id
  ORDER BY c.name;
     tenant     | client  
----------------+---------
 Lavender Salon | Aigerim
 Lavender Salon | Dana
(2 rows)

SELECT count(*) AS rows_of_b FROM app.clients WHERE tenant_id = '22222222-2222-4222-8222-222222222222';
 rows_of_b 
-----------
         0
(1 row)

UPDATE app.clients SET name = 'Hacked' WHERE tenant_id = '22222222-2222-4222-8222-222222222222';
UPDATE 0
DELETE FROM app.bookings WHERE tenant_id = '22222222-2222-4222-8222-222222222222';
DELETE 0
COMMIT;
COMMIT
BEGIN;
BEGIN
SELECT set_config('app.tenant_id', '11111111-1111-4111-8111-111111111111', true);
              set_config              
--------------------------------------
 11111111-1111-4111-8111-111111111111
(1 row)

INSERT INTO app.clients (tenant_id, name) VALUES ('22222222-2222-4222-8222-222222222222', 'Mallory');
psql:/sql/03-isolation.sql:22: ERROR:  new row violates row-level security policy for table "clients"
ROLLBACK;
ROLLBACK
BEGIN;
BEGIN
SELECT set_config('app.tenant_id', '11111111-1111-4111-8111-111111111111', true);
              set_config              
--------------------------------------
 11111111-1111-4111-8111-111111111111
(1 row)

INSERT INTO app.clients (name) VALUES ('Saule') RETURNING tenant_id, name;
              tenant_id               | name  
--------------------------------------+-------
 11111111-1111-4111-8111-111111111111 | Saule
(1 row)

INSERT 0 1
INSERT INTO app.bookings (client_id, starts_at)
  VALUES ('bbbbbbbb-0000-4000-8000-000000000001', '2026-11-03 12:00+05');
psql:/sql/03-isolation.sql:30: ERROR:  insert or update on table "bookings" violates foreign key constraint "bookings_tenant_id_client_id_fkey"
DETAIL:  Key is not present in table "clients".
ROLLBACK;
ROLLBACK
SELECT current_setting('app.tenant_id', true) AS after_commit,
       (SELECT count(*) FROM app.clients) AS visible_clients;
 after_commit | visible_clients 
--------------+-----------------
              |               0
(1 row)
Actual psql -e output: queries and results in order, errors where they occurred
  • Step 1: without a tenant — 0 rows, although the table holds three clients
  • Step 2: tenant A sees only its own clients; explicitly asking for B's rows returns 0, and UPDATE and DELETE of foreign rows affect 0 rows — no error, the rows are simply invisible
  • Step 3: an insert with a foreign tenant_id is rejected by the WITH CHECK policy
  • Step 4: tenant_id was filled in automatically; a booking for salon B's client failed on the composite foreign key. PostgreSQL did not print the key values in DETAIL — a user must not learn data it cannot see
  • Step 5: after ROLLBACK the setting is empty and no rows are visible again

Passing the tenant into queries: from HTTP to SET LOCAL

First the application determines the tenant from the HTTP request. The options differ in how far they can be trusted:

Subdomain

Example
lavender.example.app
What it proves
Says which salon is addressed but proves no rights: check that the signed-in user is a member of that tenant

Token claim

Example
tenant_id in a signed JWT or a server-side session
What it proves
The signature or session was issued by the server at sign-in — the most reliable option

Header

Example
X-Tenant-Id: …
What it proves
Only if a trusted gateway sets it or the value is checked against the user's membership; otherwise any client can send someone else's ID

Then the tenant reaches the database. The key decision is how long the setting lives. SET without LOCAL lasts until the end of the session, that is, until the connection closes. In a connection pool, one connection serves different HTTP requests in turn, and a forgotten setting from the previous request is inherited by the next — the pitfalls section shows this in practice. SET LOCAL and set_config(name, value, true) last only until the end of the current transaction: after COMMIT or ROLLBACK the value is reset. The function is handier than the command: it accepts a $1 parameter, so no SQL is assembled from strings.

PgBouncer in transaction pooling mode assigns a server connection to a client only for the duration of a transaction, and its compatibility table marks SET/RESET as unsupported in that mode. A transaction-level setting lives exactly as long as the connection is assigned to the client, so it works with both kinds of pool.

package.json
{
  "name": "tenant-isolation",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "test": "node --test"
  },
  "dependencies": {
    "pg": "^8.23.1"
  }
}
src/db.js
import pg from "pg";

export const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 5 });

const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

// Every tenant query runs in a transaction with a transaction-local tenant:
// set_config(..., true) is reset on COMMIT/ROLLBACK, so a pooled connection
// never carries the previous request's tenant.
export async function withTenant(tenantId, fn, db = pool) {
  if (!UUID.test(tenantId)) throw new Error("withTenant: tenantId must be a UUID");
  const client = await db.connect();
  try {
    await client.query("BEGIN");
    await client.query("SELECT set_config('app.tenant_id', $1, true)", [tenantId]);
    const result = await fn(client);
    await client.query("COMMIT");
    return result;
  } catch (err) {
    await client.query("ROLLBACK").catch(() => {});
    throw err;
  } finally {
    client.release();
  }
}
A wrapper around pg.Pool: all code touching tenant data gets a connection only through withTenant

Pitfalls: who bypasses RLS and how

The PostgreSQL docs say it plainly: superusers and roles with the BYPASSRLS attribute always bypass row security, and so does the table owner, unless FORCE ROW LEVEL SECURITY is set. Let us verify that, plus two more pitfalls, as the superuser:

sql/04-pitfalls.sql
-- Run as the superuser: psql -U postgres -d saas -f 04-pitfalls.sql
-- 1. Superusers bypass RLS: every tenant is visible
SELECT current_user, count(*) AS clients FROM app.clients;

-- 2. So does any role with BYPASSRLS
CREATE ROLE reporting LOGIN BYPASSRLS;
GRANT USAGE ON SCHEMA app TO reporting;
GRANT SELECT ON app.clients TO reporting;
SET ROLE reporting;
SELECT current_user, count(*) AS clients FROM app.clients;
RESET ROLE;

-- 3. Without FORCE, the table owner bypasses RLS
ALTER TABLE app.clients NO FORCE ROW LEVEL SECURITY;
SET ROLE app_owner;
SELECT current_user, count(*) AS clients_no_force FROM app.clients;
RESET ROLE;
ALTER TABLE app.clients FORCE ROW LEVEL SECURITY;
SET ROLE app_owner;
SELECT current_user, count(*) AS clients_force FROM app.clients;
RESET ROLE;

-- 4. A view checks RLS as its owner: a superuser-owned view leaks everything
CREATE VIEW app.client_names AS SELECT tenant_id, name FROM app.clients;
GRANT SELECT ON app.client_names TO app_user;
SET ROLE app_user;
SELECT count(*) AS via_view_without_tenant FROM app.client_names;
RESET ROLE;
ALTER VIEW app.client_names SET (security_invoker = true);
SET ROLE app_user;
SELECT count(*) AS via_invoker_view FROM app.client_names;
RESET ROLE;

-- 5. Session-level SET survives the "request" on a pooled connection
SET ROLE app_user;
SET app.tenant_id = '11111111-1111-4111-8111-111111111111';   -- request 1, tenant A
SELECT count(*) AS request_1 FROM app.clients;
-- request 2 for another tenant forgets to set its tenant and reuses the connection
SELECT count(*) AS request_2_sees_a FROM app.clients;
RESET app.tenant_id;
RESET ROLE;
terminal
$ docker exec kb-tenants sh -c "psql -U postgres -d saas -X -e -f /sql/04-pitfalls.sql 2>&1"
SELECT current_user, count(*) AS clients FROM app.clients;
 current_user | clients 
--------------+---------
 postgres     |       3
(1 row)

CREATE ROLE reporting LOGIN BYPASSRLS;
CREATE ROLE
GRANT USAGE ON SCHEMA app TO reporting;
GRANT
GRANT SELECT ON app.clients TO reporting;
GRANT
SET ROLE reporting;
SET
SELECT current_user, count(*) AS clients FROM app.clients;
 current_user | clients 
--------------+---------
 reporting    |       3
(1 row)

RESET ROLE;
RESET
ALTER TABLE app.clients NO FORCE ROW LEVEL SECURITY;
ALTER TABLE
SET ROLE app_owner;
SET
SELECT current_user, count(*) AS clients_no_force FROM app.clients;
 current_user | clients_no_force 
--------------+------------------
 app_owner    |                3
(1 row)

RESET ROLE;
RESET
ALTER TABLE app.clients FORCE ROW LEVEL SECURITY;
ALTER TABLE
SET ROLE app_owner;
SET
SELECT current_user, count(*) AS clients_force FROM app.clients;
 current_user | clients_force 
--------------+---------------
 app_owner    |             0
(1 row)

RESET ROLE;
RESET
CREATE VIEW app.client_names AS SELECT tenant_id, name FROM app.clients;
CREATE VIEW
GRANT SELECT ON app.client_names TO app_user;
GRANT
SET ROLE app_user;
SET
SELECT count(*) AS via_view_without_tenant FROM app.client_names;
 via_view_without_tenant 
-------------------------
                       3
(1 row)

RESET ROLE;
RESET
ALTER VIEW app.client_names SET (security_invoker = true);
ALTER VIEW
SET ROLE app_user;
SET
SELECT count(*) AS via_invoker_view FROM app.client_names;
 via_invoker_view 
------------------
                0
(1 row)

RESET ROLE;
RESET
SET ROLE app_user;
SET
SET app.tenant_id = '11111111-1111-4111-8111-111111111111';
SET
SELECT count(*) AS request_1 FROM app.clients;
 request_1 
-----------
         2
(1 row)

SELECT count(*) AS request_2_sees_a FROM app.clients;
 request_2_sees_a 
------------------
                2
(1 row)

RESET app.tenant_id;
RESET
RESET ROLE;
RESET
Actual output on the same bench

The application connects as a superuser

What the output shows
postgres sees all 3 clients
Defence
A dedicated NOSUPERUSER NOBYPASSRLS application role

A BYPASSRLS role for reporting or support

What the output shows
reporting sees everyone
Defence
Grant it only where "all tenants" is a deliberate requirement, and log the access

Owner without FORCE

What the output shows
app_owner: 3 rows without FORCE, 0 with it
Defence
FORCE ROW LEVEL SECURITY on every table

A view checks the owner's privileges

What the output shows
A superuser-owned view gave app_user all 3 rows without a tenant
Defence
security_invoker = true (per the CREATE VIEW docs, the invoking user's policies then apply)

Session-level SET on a pooled connection

What the output shows
"Request 2" without its own tenant saw A's 2 rows
Defence
Only set_config(..., true) or SET LOCAL inside a transaction
  • pg_dump sets row_security = off by default: with it, a query that a policy would filter fails with an error instead. So backups are taken by a role that bypasses RLS — and stored as data of all tenants at once
  • Migrations run as the schema owner; with FORCE, a data migration must set the tenant itself or run as a role explicitly allowed to bypass
  • Background jobs across all tenants are easier to write as a loop: one withTenant transaction per tenant rather than a single BYPASSRLS role
  • SECURITY DEFINER functions run with the owner's privileges and can return rows that bypass the caller's policies — list them and review them

Isolation tests

Microsoft advises testing the chosen model to verify that one tenant's data does not accidentally leak to another. Below is a node:test suite that connects as the application role to the bench from the previous sections. The last test is a guard: it reads the system catalog and fails if the schema gains a table with tenant_id without RLS enabled and forced, a view without security_invoker, or if the application role gets BYPASSRLS. Such a test catches a mistake in a new migration before it reaches production.

test/isolation.test.js
import { test, after } from "node:test";
import assert from "node:assert/strict";
import pg from "pg";
import { pool, withTenant } from "../src/db.js";

const A = "11111111-1111-4111-8111-111111111111";
const B = "22222222-2222-4222-8222-222222222222";
const AIGERIM = "aaaaaaaa-0000-4000-8000-000000000001";

after(() => pool.end());

const names = (tenant) =>
  withTenant(tenant, async (db) =>
    (await db.query("SELECT name FROM app.clients ORDER BY name")).rows.map((r) => r.name));

test("each tenant sees only its own clients", async () => {
  assert.deepEqual(await names(A), ["Aigerim", "Dana"]);
  assert.deepEqual(await names(B), ["Timur"]);
});

test("tenant B cannot read tenant A's row even by primary key", async () => {
  const rows = await withTenant(B, async (db) =>
    (await db.query("SELECT * FROM app.clients WHERE id = $1", [AIGERIM])).rows);
  assert.deepEqual(rows, []);
});

test("writing a row into another tenant is rejected (42501)", async () => {
  await assert.rejects(
    withTenant(A, (db) =>
      db.query("INSERT INTO app.clients (tenant_id, name) VALUES ($1, 'Mallory')", [B])),
    { code: "42501" },
  );
});

test("without a tenant nothing is visible", async () => {
  const { rows } = await pool.query("SELECT count(*)::int AS n FROM app.clients");
  assert.equal(rows[0].n, 0);
});

test("a pooled connection does not carry the tenant into the next request", async () => {
  const single = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 1 });
  try {
    await withTenant(A, (db) => db.query("SELECT 1"), single);
    const { rows } = await single.query(
      "SELECT current_setting('app.tenant_id', true) AS t, (SELECT count(*)::int FROM app.clients) AS n");
    assert.deepEqual(rows[0], { t: "", n: 0 });
  } finally {
    await single.end();
  }
});

test("concurrent requests of different tenants stay isolated", async () => {
  const seen = await Promise.all(
    Array.from({ length: 40 }, (_, i) => {
      const tenant = i % 2 ? A : B;
      return withTenant(tenant, async (db) => {
        await db.query("SELECT pg_sleep(0.01)");
        const { rows } = await db.query("SELECT DISTINCT tenant_id FROM app.clients");
        return { tenant, ids: rows.map((r) => r.tenant_id) };
      });
    }),
  );
  for (const { tenant, ids } of seen) assert.deepEqual(ids, [tenant]);
});

test("guard: RLS is enabled and forced everywhere, the app role cannot bypass it", async () => {
  const unprotected = await pool.query(`
    SELECT c.relname
      FROM pg_class c
      JOIN pg_namespace n ON n.oid = c.relnamespace
      JOIN pg_attribute a ON a.attrelid = c.oid AND a.attname = 'tenant_id' AND NOT a.attisdropped
     WHERE n.nspname = 'app' AND c.relkind = 'r'
       AND NOT (c.relrowsecurity AND c.relforcerowsecurity)`);
  assert.deepEqual(unprotected.rows, []);

  const ownerViews = await pool.query(`
    SELECT c.relname
      FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
     WHERE n.nspname = 'app' AND c.relkind = 'v'
       AND NOT coalesce('security_invoker=true' = ANY (c.reloptions), false)`);
  assert.deepEqual(ownerViews.rows, []);

  const role = await pool.query(
    "SELECT rolsuper, rolbypassrls FROM pg_roles WHERE rolname = current_user");
  assert.deepEqual(role.rows[0], { rolsuper: false, rolbypassrls: false });
});
terminal
$ DATABASE_URL=postgres://app_user:change-me@127.0.0.1:55439/saas npm test
> tenant-isolation@1.0.0 test
> node --test

✔ each tenant sees only its own clients (19.0221ms)
✔ tenant B cannot read tenant A's row even by primary key (2.6029ms)
✔ writing a row into another tenant is rejected (42501) (2.7856ms)
✔ without a tenant nothing is visible (1.1808ms)
✔ a pooled connection does not carry the tenant into the next request (9.664ms)
✔ concurrent requests of different tenants stay isolated (109.7941ms)
✔ guard: RLS is enabled and forced everywhere, the app role cannot bypass it (5.9816ms)
ℹ tests 7
ℹ suites 0
ℹ pass 7
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 268.1635

To make sure the tests are not a formality, we checked them with mutations on the same bench:

set_config(..., false) instead of true in withTenant

What failed
The pool and no-tenant tests: the setting outlived the transaction

NO FORCE ROW LEVEL SECURITY on bookings

What failed
The guard test

A view without security_invoker

What failed
The guard test

An extra FOR SELECT USING (true) policy

What failed
Five read tests: permissive policies are combined with OR

ALTER ROLE app_user BYPASSRLS

What failed
All seven tests
  • Every table with tenant data has tenant_id; primary and foreign keys are composite
  • RLS is enabled and forced (FORCE) on every such table, with USING and WITH CHECK policies
  • The application connects as a role without SUPERUSER and BYPASSRLS; tables are owned by another role
  • The tenant is passed only via set_config(..., true) or SET LOCAL inside a transaction
  • Views are created with security_invoker = true
  • The catalog guard test and the leak tests run in CI

Noisy neighbours, limits and billing: an overview

Data isolation is not the only concern of a shared model. A noisy neighbour is a tenant whose load degrades service for the others: Microsoft lists it among the risks of shared infrastructure, and less mutual impact among the benefits of dedicated deployments. Defences live at different levels:

API

Tool
Per-tenant and per-plan rate limits, 429 with Retry-After
Note
The limit key is tenant_id, not the IP address

Application

Tool
Plan quotas: number of masters, bookings, file sizes
Note
The plan is stored on the tenant (plan) and checked on the server

Database

Tool
statement_timeout for the application role, pagination, indexes with tenant_id
Note
The timeout aborts any statement that runs too long, but it applies to the role, not to a specific tenant

Background jobs

Tool
A queue that shares capacity fairly between tenants
Note
Otherwise one salon's large import delays everyone's notifications

Infrastructure

Tool
Moving a large tenant to its own database
Note
The bridge model: shared code, dedicated data

Microsoft covers billing and consumption metering separately: in a shared database it is hard to measure exactly what each tenant consumed. The options are an indicative metric (such as a tenant's data volume), counting key transactions (bookings created), counting API requests, or estimating a share of the total cost. Billing metrics belong in a reliable store, not in a telemetry system that samples: Microsoft warns that such systems drop part of the data.

In our schema the natural unit is a created booking: an event with tenant_id is written to a usage table in the same transaction as the booking itself, so the count never drifts from the data. This is advice, not a standard: pick the metric that matches the product's pricing model.

How this was verified2 · passed1 · not runchecked 11 October 2026

“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.