How Versus enforces tenant isolation in the database itself — the mechanism, the guarantees it makes, its place among the patterns used across enterprise SaaS, and the detail a security team needs to evaluate it.
A multi-tenant platform stores many customers' data in shared infrastructure. The central security question is unforgiving: "Can tenant A ever see or modify tenant B's data?" Every affirmative answer is a reportable breach.
The conventional answer is to filter every query in application code — WHERE tenant_id = :current — added by hand or by an ORM scope. That works until the one place it is forgotten: a new endpoint, a background job, a raw report, an ORM method that bypasses the default scope, an injection flaw that strips the clause. A single missed filter is a cross-tenant leak, and the filter lives in exactly the layer that changes most and is written by the most people.
Row-Level Security (RLS) moves the boundary down a layer — into the database, below all application code. The rule that a query may only touch the current tenant's rows becomes a property of the table, enforced by PostgreSQL on every statement, regardless of what the application above it does or forgets to do. It converts "we always remember to filter" into "the database refuses to return anything else."
The industry describes a spectrum, trading isolation strength against cost and density:
Strongest physical isolation; highest cost, slowest onboarding. Common for regulated single-customer deployments.
Shared database, schema per tenant. Logical separation without per-tenant infrastructure, but schema sprawl grows with tenant count.
All tenants share tables; a tenant_id column plus database-enforced RLS separates them. Highest density and simplest operations — where Versus operates.
RLS is a native PostgreSQL feature (equivalent capabilities exist in Microsoft SQL Server, Oracle VPD, and others). Once enabled on a table, every SELECT, INSERT, UPDATE, andDELETE is transparently constrained by a policy — a boolean expression the database evaluates per row. A policy has two independent predicates:
USING — the visibility predicate. Rows for which it is false simply do not exist for the query; they cannot be read, updated, or deleted.WITH CHECK — the write predicate. An insert or update whose values fail it errors. This is what stops a tenant from writing a row into another tenant.The predicate reads "who am I" from a session variable the application sets at the start of each transaction — the canonical multi-tenant shape.
FORCE ROW LEVEL SECURITY — without it, RLS can be security theater. (2) If the "who am I" context is read permissively when unset, a naïve policy can fall open. A secure implementation makes the missing-context case fail, not fall open. Both are load-bearing in Versus.Versus enforces tenant isolation in two independent layers: a database policy that cannot be bypassed from application code, and a per-request mechanism that establishes who the caller is from a signed token — never from client input.
Every tenant-scoped table carries a tenant_id column and a uniform policy: enable and FORCE ROW LEVEL SECURITY, then a tenant_isolation policy whose USING and WITH CHECK predicates compare tenant_id against the session's tenant setting. Two deliberate choices make it fail-closed:
FORCE is mandatory. The application's own database role is subject to the policy — it cannot read around it. Only a superuser or BYPASSRLS role is exempt, and the application never connects as one.Before any tenant-scoped query runs, Versus establishes the caller's tenant for the current transaction. The identity is taken from the authenticated token's tenant_id claim — minted and signed server-side at login — and applied with a transaction-local setting.
WITH CHECKA database trigger stamps a new row's tenant_id from the session context when it is left empty, so application code never has to supply it and cannot get it wrong. Because the same policy's WITH CHECK runs on every insert and update, a caller cannot write a row bearing another tenant's id — the database rejects it. Reads and writes are governed by one predicate, so they cannot drift apart.
Tenant traffic runs over a dedicated, non-superuser role with neither BYPASSRLS nor table ownership. This is what gives FORCE its teeth — even a totally unfiltered query is physically incapable of seeing across the policy. Privileged operations use a different connection and never serve tenant user traffic.
Login mints a signed token carrying the tenant claim → the request layer reads it from the verified token → it is written into a transaction-local database setting → the policy compares each row against it. At no point can a request re-address itself to another tenant.
| Threat / failure mode | Result | Mechanism |
|---|---|---|
| Cross-tenant read (IDOR — request another tenant’s id) | Blocked | Row fails the USING predicate → it does not exist for the query |
| Cross-tenant write / update into another tenant | Blocked | WITH CHECK rejects any insert/update whose tenant_id ≠ the session tenant |
| Developer forgets a WHERE tenant_id filter | Contained | The database still scopes the result — the forgotten filter is redundant, not a breach |
| ORM method or raw SQL that bypasses app-layer scoping | Contained | RLS is below the ORM; every statement on the scoped connection is constrained |
| SQL injection neutralizing an app-level WHERE clause | Mitigated | The policy predicate isn’t in the injectable query text; blast radius capped to one tenant |
| Forged / altered tenant id in a request | Blocked | Tenant comes from the signed token claim; tampering invalidates the signature |
| Query reaches a scoped table with no tenant context | Fails closed | Session lookup without “missing OK” raises an error rather than leaking |
| Leftover context bleeding across a pooled connection | Blocked | Transaction-scoped setting cleared at commit/rollback before reuse |
| App’s own DB role trying to read around the policy | Blocked | FORCE ROW LEVEL SECURITY + a non-owner, non-BYPASSRLS role |
BYPASSRLS roles are exempt — a PostgreSQL invariant. The mitigation is architectural: tenant user traffic never flows over such a role; administrative operations use a separate connection with their own authorization and audit.Row-scoped isolation is the mainstream approach to pooled multi-tenancy — a well-trodden, auditable pattern, not an in-house invention.
| Platform | Relationship to Versus's approach |
|---|---|
| Salesforce | The archetype: every row carries an OrgID and the platform injects the tenant predicate on every access — the conceptual ancestor of RLS, enforced in the platform. |
| AWS (SaaS guidance) | Names the silo/bridge/pool models and recommends PostgreSQL RLS with a session-context tenant as the cost-efficient pooled pattern — the exact model Versus implements. |
| Supabase | Makes PostgreSQL RLS the front-line authorization mechanism; policies reference the verified JWT — same primitive, same “identity from a signed token” pattern. |
| Azure Cosmos DB for PostgreSQL (Citus) | Documents RLS on the tenant column as the isolation control for distributed B2B multi-tenant apps. |
| Microsoft SQL Server / Azure SQL | Native RLS via security policies and predicate functions — the same concept in a different engine. |
| Oracle Database | Virtual Private Database (VPD) transparently appends a predicate to queries — the enterprise forerunner of modern RLS. |
The consistent thread: a tenant discriminator on every row, an identity taken from a trusted server-side context, and a predicate applied beneath application code. Versus's implementation is a faithful, forced, fail-closed instance of this consensus pattern on PostgreSQL.
| Question | Versus |
|---|---|
| Is RLS FORCEd so the app’s own role can’t bypass it? | Yes — FORCE on every scoped table; tenant traffic runs as a non-owner role. |
| Does an unset tenant context fail closed? | Yes — the session lookup has no “missing OK” flag; a scoped query with no context errors rather than leaking. |
| Where does the tenant identity come from — can a client forge it? | From the signed token’s tenant_id claim, applied server-side. Not a request parameter; tampering breaks the signature. |
| Are writes protected, not just reads? | Yes — a WITH CHECK predicate plus a server-side stamping trigger. |
| Is it safe under connection pooling? | Yes — transaction-scoped setting, cleared automatically at transaction end. |
| Does tenant traffic ever run as a superuser / BYPASSRLS role? | No — a dedicated least-privilege connection; privileged operations are separated off the tenant path. |
| Is isolation tested, or just asserted? | A conformance suite verifies cross-tenant reads/writes are blocked and fail-closed behavior holds, as regression tests. |
| What’s the failure direction if misconfigured? | Toward “not multi-tenant” or “data not shown,” never toward “shown to the wrong tenant.” |