Tutorial

Multi-tenant isolation with Postgres RLS

How the platform's own Postgres database enforces per-tenant isolation at the row level — for anyone evaluating whether this is safe to trust with multiple customers' data.

Why row-level, not app-level, isolation

The obvious way to build a multi-tenant SaaS is application-level filtering: every query gets a WHERE tenant_id = ? clause, added by the developer, by hand, every time. That works — until one route, one background job, or one new engineer forgets it. The failure mode is silent: the query still runs, still returns rows, and nothing errors. It just returns the wrong tenant's rows.

Postgres Row-Level Security (RLS) moves that check out of application code and into the database engine itself. A policy attached to a table is evaluated by Postgres on every SELECT, INSERT, UPDATE, and DELETE — regardless of what the query looked like. A route that forgets its WHERE clause doesn't leak; it just gets fewer rows than the developer expected, which fails loudly in testing rather than leaking silently in production.

The tenant_id + policy pattern

Every tenant-owned table in this platform's schema — environment, contract, mcp_server, tool, audit_event, and more — carries a tenant_id column and has RLS enabled with a single policy:

ALTER TABLE mcp_server ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation ON mcp_server USING (tenant_id = current_setting('app.tenant_id', true)) WITH CHECK (tenant_id = current_setting('app.tenant_id', true));

The USING clause filters what a query can read; WITH CHECK validates what it's allowed to write. Together they mean: no query issued by the application role, no matter how it's written, can read or write a row belonging to a different tenant. This same block runs identically across every tenant-owned table in the schema — it's applied in a single migration loop, not copy-pasted per table, so there's no table anyone could forget to cover.

Setting tenant context per request

RLS needs to know which tenant is "current" for the policy's current_setting('app.tenant_id') to resolve. This platform sets that as a Postgres session variable at the start of every request, scoped to a single transaction:

// every tenant-scoped request runs inside this wrapper async function withTenant(tenantId, fn) { const client = await pool.connect(); try { await client.query("BEGIN"); // set_config(..., true) => local to this transaction only await client.query("SELECT set_config('app.tenant_id', $1, true)", [tenantId]); const result = await fn(client); await client.query("COMMIT"); return result; } finally { client.release(); } }

The tenantId passed in here comes from the verified, authenticated session — never from a request body or query parameter a client could forge. Because the setting is transaction-local (the third argument to set_config), it can't leak from one pooled connection into the next request that happens to reuse it.

The actual attack this defends against: a route handler that queries tool or audit_event without an explicit tenant filter — because of a bug, a refactor, or a new endpoint written in a hurry — still cannot return another tenant's rows. The database refuses them before the query result ever reaches application code.

The owner exemption, and why it's still safe

Postgres has a subtlety here: by default, RLS does not apply to a table's owner role, even with ENABLE ROW LEVEL SECURITY set. There's a stricter FORCE ROW LEVEL SECURITY that would remove that exemption — but on managed Postgres (this platform runs on Cloud SQL), no unprivileged role can hold the BYPASSRLS attribute, and forcing RLS on the owner would also block that same owner role from running migrations and seed scripts. So the schema deliberately uses NO FORCE and leans on a second, independent guarantee instead: the running application process is not allowed to connect as the owner at all.

// checked at boot — the app refuses to start if this fails const { rows } = await pool.query( "SELECT current_user AS role, rolsuper, rolbypassrls FROM pg_roles WHERE rolname = current_user" ); if (rows[0].rolsuper || rows[0].rolbypassrls) { throw new Error( 'Refusing to start: connected as "' + rows[0].role + '", which bypasses RLS.' ); }

In other words: the migration/seed role (mcp, the owner) is exempt from RLS and is only ever used offline, outside the request path, with an explicit ALLOW_BYPASSRLS=1 flag. The runtime role that actually serves traffic (mcp_app) is a separate, non-owner, non-superuser role with no bypass privilege — so for every request the application actually handles, the tenant_isolation policy is fully enforced. This check runs at process boot specifically so a misconfigured DATABASE_URL (e.g. accidentally pointing at the owner credentials) fails loudly at startup instead of quietly disabling isolation in production.

The admin console's separate BYPASSRLS pool

Cross-tenant visibility is occasionally a legitimate, intentional need — a super-admin dashboard that shows server counts across every tenant, for instance. Rather than weakening the tenant-facing RLS policy to allow this, the platform keeps a second, completely separate connection pool that intentionally bypasses RLS, used only by admin endpoints:

// admin-db.js — separate pool, owner role, BYPASSRLS on purpose const adminPool = new Pool({ connectionString: process.env.ADMIN_DATABASE_URL || "postgres://mcp:mcp@localhost:5432/mcp_platform", max: 3, }); async function adminQuery(sql, params) { return adminPool.query(sql, params); // no tenant scoping — by design }

This pool is never mounted on a tenant-facing route — it's wired only into admin endpoints that sit behind a requireAdmin check, so a compromised tenant credential can never reach it. Keeping this pool structurally separate from the request-scoped withTenant pool means the tenant-isolation guarantee for ordinary traffic is never weakened, diluted, or made conditional just to support one dashboard.

Verifying it yourself

You don't have to take this on faith. Once you have two tenants (or ask us for a sandbox demo), call the bootstrap endpoint with different X-Tenant-Id values and confirm you only ever see your own tenant's rows:

GET /v1/bootstrap -H "X-Tenant-Id: ten_a" → servers, tools, contracts scoped to ten_a only GET /v1/bootstrap -H "X-Tenant-Id: ten_b" → different rows entirely — zero overlap with ten_a

See the security section of the docs for the API reference, or read the flagship deep-dive for the full implementation walk-through aimed at engineers evaluating platform trust.

Building this yourself instead? MCP Platform vs. DIY lays out what it takes to replicate this isolation model on your own infrastructure.