Security & Compliance

Row-Level Security & Tenant Isolation

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.

The isolation problem

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."


Three tenancy models — where Versus sits

The industry describes a spectrum, trading isolation strength against cost and density:

Silo — DB per tenant

Strongest physical isolation; highest cost, slowest onboarding. Common for regulated single-customer deployments.

Bridge — schema per tenant

Shared database, schema per tenant. Logical separation without per-tenant infrastructure, but schema sprawl grows with tenant count.

Pool — shared schema + RLS

All tenants share tables; a tenant_id column plus database-enforced RLS separates them. Highest density and simplest operations — where Versus operates.

NoteThe pool model is the most demanding to secure and the most efficient to run. Versus adopts it because PostgreSQL RLS makes the isolation a hard database guarantee rather than an application convention — and Versus also supports fully isolated on-premises / single-tenant deployments where the same code runs with RLS inert. Silo-grade isolation is available by deployment topology; pooled SaaS tenants get RLS.

What Row-Level Security actually is

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.

NoteTwo subtleties decide whether RLS is real. (1) Table owners bypass RLS unless you declare 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.

How Versus implements it

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.

Layer one — the database policy (forced, fail-closed)

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.
  • The session lookup has no "missing OK" flag. If a query reaches a protected table without the tenant context set for that transaction, PostgreSQL raises an error and the query fails. There is no "context unset ⇒ see everything" path — an unscoped query on a scoped table is a hard failure, surfaced immediately rather than leaking.

Layer two — tenant context from a signed claim

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.

  • The tenant is never client-supplied. It is not a URL parameter, header, or body field. It comes from the cryptographically signed session token, so a user cannot ask to act as another tenant by editing a request.
  • The setting is transaction-scoped. It exists only for the current transaction and is discarded on commit or rollback — it cannot leak into the next request that reuses the same pooled connection.
  • The scope wraps the whole request. Context is established after authentication and torn down when the response is finalized, so every query the view issues — including ones a developer forgot to filter — is covered.

The write path — server-side stamping + WITH CHECK

A 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.

A least-privilege connection role

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.

Server-side at every step

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.

NoteThe same codebase powers isolated on-premises installs. There the tenant-scoped connection is simply not configured and the mechanism is inert — the app behaves as an ordinary single-tenant application. Isolation is additive: a misconfiguration fails toward "not multi-tenant," never toward "multi-tenant without isolation."

Threat model & the guarantees it makes

Threat / failure modeResultMechanism
Cross-tenant read (IDOR — request another tenant’s id)BlockedRow fails the USING predicate → it does not exist for the query
Cross-tenant write / update into another tenantBlockedWITH CHECK rejects any insert/update whose tenant_id ≠ the session tenant
Developer forgets a WHERE tenant_id filterContainedThe database still scopes the result — the forgotten filter is redundant, not a breach
ORM method or raw SQL that bypasses app-layer scopingContainedRLS is below the ORM; every statement on the scoped connection is constrained
SQL injection neutralizing an app-level WHERE clauseMitigatedThe policy predicate isn’t in the injectable query text; blast radius capped to one tenant
Forged / altered tenant id in a requestBlockedTenant comes from the signed token claim; tampering invalidates the signature
Query reaches a scoped table with no tenant contextFails closedSession lookup without “missing OK” raises an error rather than leaking
Leftover context bleeding across a pooled connectionBlockedTransaction-scoped setting cleared at commit/rollback before reuse
App’s own DB role trying to read around the policyBlockedFORCE ROW LEVEL SECURITY + a non-owner, non-BYPASSRLS role
NoteThe core guarantee: on the tenant-serving connection, a query can only ever observe or modify rows belonging to the tenant named in the caller's signed token — and this holds even against unfiltered queries, ORM bugs, and forgotten scopes. Isolation is a property enforced by PostgreSQL, not a discipline maintained by developers.

Residual risks & how they're controlled

  • Privileged connections bypass RLS by design. Superuser / 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.
  • Connection pooling. Versus uses transaction-scoped settings (cleared at transaction end) rather than session-level settings that would persist and could bleed between tenants.
  • Unstamped rows. A row written with no tenant context is invisible to tenant users (the safe direction) rather than visible to the wrong one; background writers establish the tenant context or stamp explicitly so data surfaces for its owner.
  • Policy drift. Every table's policy is generated from one shared definition, so the predicate shape cannot vary table to table.
  • Proven, not assumed. An automated conformance suite asserts that a query under tenant A cannot read or write tenant B, that the missing-context case fails closed, and that FORCE holds against the app role — as regression tests, so a change that weakened isolation breaks the build.

How major SaaS platforms use the same idea

Row-scoped isolation is the mainstream approach to pooled multi-tenancy — a well-trodden, auditable pattern, not an in-house invention.

PlatformRelationship to Versus's approach
SalesforceThe 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.
SupabaseMakes 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 SQLNative RLS via security policies and predicate functions — the same concept in a different engine.
Oracle DatabaseVirtual 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.


Evaluator's checklist

QuestionVersus
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.”