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.
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
USINGandWITH CHECKpolicies,FORCEand a dedicated application role withoutBYPASSRLS - 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_idis left without RLS
Know beforehand
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
- What multi-tenancy is and where a tenant ends
- Three isolation models and their cost
- tenant_id everywhere: schema, keys and indexes
- Row-Level Security in PostgreSQL: USING and WITH CHECK policies
- Passing the tenant into queries: from HTTP to SET LOCAL
- Pitfalls: who bypasses RLS and how
- Isolation tests
- Noisy 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_idand RLS policies - Schema per tenant
- Separate objects;
search_pathand 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.
-- 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;change-me password is a placeholder for the local test bench; a real system takes it from a secrets managertenant_idin every tableChild tables too:
bookingsdoes not derive its tenant by joiningclients, 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.Composite primary key
PRIMARY KEY (tenant_id, id)makestenant_idthe leading index column, so queries within a tenant use it naturally.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 onclient_idwould allow pointing at another tenant's client.Indexes start with
tenant_idAlmost every query filters by tenant, so
(tenant_id, starts_at)is more useful than(starts_at). Unique constraints includetenant_idas well: a phone number is unique within a salon, not across the system.A default value
DEFAULT app.current_tenant()fills in the tenant of the current transaction, so insert code does not passtenant_idby 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.
-- 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;$ 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)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
UPDATEandDELETE;falseorNULLhides 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
INSERTorUPDATE;falseorNULLraises 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
ANDon 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:
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.sqlbash 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-- 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');-- 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;$ 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)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
UPDATEandDELETEof foreign rows affect 0 rows — no error, the rows are simply invisible - Step 3: an insert with a foreign
tenant_idis rejected by theWITH CHECKpolicy - Step 4:
tenant_idwas filled in automatically; a booking for salon B's client failed on the composite foreign key. PostgreSQL did not print the key values inDETAIL— a user must not learn data it cannot see - Step 5: after
ROLLBACKthe 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_idin 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.
{
"name": "tenant-isolation",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"test": "node --test"
},
"dependencies": {
"pg": "^8.23.1"
}
}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();
}
}pg.Pool: all code touching tenant data gets a connection only through withTenantPitfalls: 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:
-- 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;$ 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;
RESETThe application connects as a superuser
- What the output shows
postgressees all 3 clients- Defence
- A dedicated
NOSUPERUSER NOBYPASSRLSapplication role
A BYPASSRLS role for reporting or support
- What the output shows
reportingsees 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 withoutFORCE, 0 with it- Defence
FORCE ROW LEVEL SECURITYon every table
A view checks the owner's privileges
- What the output shows
- A superuser-owned view gave
app_userall 3 rows without a tenant - Defence
security_invoker = true(per theCREATE VIEWdocs, 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)orSET LOCALinside a transaction
pg_dumpsetsrow_security = offby 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
withTenanttransaction per tenant rather than a singleBYPASSRLSrole SECURITY DEFINERfunctions 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.
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 });
});$ 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.1635To 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, withUSINGandWITH CHECKpolicies - The application connects as a role without
SUPERUSERandBYPASSRLS; tables are owned by another role - The tenant is passed only via
set_config(..., true)orSET LOCALinside 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,
429withRetry-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_timeoutfor the application role, pagination, indexes withtenant_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.