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
Top comments (0)