Almost every SaaS product has to answer one early question: how do we keep one customer's data away from another's? Get it wrong and you have a data leak. Get it overcomplicated and you slow down every feature.
This post covers the common options and walks through one of the most practical: a shared schema protected by Postgres row-level security (RLS).
The three common tenancy models
| Model | Isolation | Cost and ops | Good for |
|---|---|---|---|
Shared schema (tenant_id column) | Logical | Cheapest, simplest to run | Many small tenants |
| Schema per tenant | Stronger | Migrations multiply | Tens to hundreds of tenants |
| Database per tenant | Strongest | Most expensive | Compliance-heavy or large customers |
Most early-stage products start with the shared schema. The risk is simple: one forgotten WHERE tenant_id = ? leaks data. RLS moves that rule from application code into the database, so it can't be forgotten.
Step 1: Add a tenant column
CREATE TABLE invoices ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id uuid NOT NULL, amount numeric(12,2) NOT NULL, created_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX invoices_tenant_idx ON invoices (tenant_id); Index tenant_id on every tenant-scoped table. Nearly every query will filter on it.
Step 2: Enable row-level security
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY; ALTER TABLE invoices FORCE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation ON invoices USING (tenant_id = current_setting('app.current_tenant', true)::uuid) WITH CHECK (tenant_id = current_setting('app.current_tenant', true)::uuid); A few details matter here:
-
USINGcontrols which rows you can read, update or delete. -
WITH CHECKcontrols which rows you can insert or update into. - Table owners bypass RLS unless you use
FORCE ROW LEVEL SECURITY. - Superusers and roles with
BYPASSRLSalways bypass it, so your application should connect as a normal, limited role. - If the setting is missing,
current_setting(..., true)returnsNULL, the comparison is false, and no rows are returned. That is a safe default.
Step 3: Set the tenant for each request
Set the tenant inside a transaction, using set_config with true so it only lasts for that transaction. This also matters if you use a connection pool, because a setting that leaks to the next request would be dangerous.
// db.js import pg from "pg"; const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL }); export async function withTenant(tenantId, fn) { const client = await pool.connect(); try { await client.query("BEGIN"); await client.query("SELECT set_config('app.current_tenant', $1, true)", [tenantId]); const result = await fn(client); await client.query("COMMIT"); return result; } catch (err) { await client.query("ROLLBACK"); throw err; } finally { client.release(); } } And a route that uses it:
app.get("/invoices", async (req, res) => { const rows = await withTenant(req.user.tenantId, async (db) => { const { rows } = await db.query("SELECT id, amount, created_at FROM invoices ORDER BY created_at DESC"); return rows; }); res.json(rows); }); Notice there is no WHERE tenant_id in that query. The database applies it.
Step 4: Test the isolation
Write a test that proves tenant A cannot see tenant B's rows. This is the most valuable test in a multi-tenant app.
test("tenants cannot read each other's invoices", async () => { await withTenant(tenantA, (db) => db.query("INSERT INTO invoices (tenant_id, amount) VALUES ($1, 100)", [tenantA])); const rows = await withTenant(tenantB, async (db) => (await db.query("SELECT * FROM invoices")).rows); expect(rows).toHaveLength(0); }); Common pitfalls
- Connecting as a superuser or table owner silently disables RLS. Check your app's role.
- Forgetting to enable RLS on new tables. Add a check to CI that lists tables with a
tenant_idcolumn but no policy. - Session-level settings with pooling. Always use transaction-scoped settings.
- Background jobs and admin tools. They need an explicit, audited way to set the tenant (or a separate role).
- Unique constraints. Usually they should include
tenant_id, for exampleUNIQUE (tenant_id, email).
When RLS isn't enough
RLS gives strong logical isolation, but it doesn't solve noisy neighbours (one tenant using all the resources), per-tenant backups or restores, or data-residency requirements. If a customer needs those, schema-per-tenant or database-per-tenant may fit better for them, and many products end up with a hybrid.
What tenancy model are you using, and what made you pick it?
Further reading: SaaS Development
