DEV Community

Cover image for Why enforcing tenant boundaries in application code is a ticking time bomb
Latch Vector
Latch Vector

Posted on

Why enforcing tenant boundaries in application code is a ticking time bomb

Here is how most multi-tenant SaaS applications isolate customer data when they start out:

// Looks safe, until someone forgets it in a new endpoint
const invoices = await db.query(
'SELECT * FROM invoices WHERE company_id = ? AND id = ?',
[req.user.companyId, req.params.id]
);
Enter fullscreen mode Exit fullscreen mode

It works fine for the first year. Then the team grows, junior engineers join, someone builds an internal bulk export feature, or a developer writes a quick direct SQL query during a midnight hotfix.

Eventually, a route ships where someone forgets WHERE company_id = ?. A customer sees another customer’s invoices, and you spend the next 72 hours writing an incident report to your legal team.

Relying exclusively on application code for multi-tenant isolation means your security boundary depends on 100% human diligence across 100% of your codebase, forever.

That is a mathematically guaranteed failure mode over time.

The "Defence in Depth" Tenant Model
When we built Latch Vector, we approached tenant isolation with a simple rule: The innermost security layer must not care whether the application developer remembered the check.

We implemented a 3-layer boundary where every layer operates independently:

Request ---> [ Layer 1: Application Guard ]
↓ (validated)
[ Layer 2: Postgres Row-Level Security (RLS) ]
↓ (enforced)
[ Layer 3: Database Schema & Triggers ]
Enter fullscreen mode Exit fullscreen mode

Here is how those layers actually work behind the scenes.

Layer 1: Fail-Closed Service Guards
Every request carrying an organization ID is checked against the caller's granted scope (SELF or SUBTREE).

If a token carries no scope, it is refused outright rather than trusted. If an endpoint requires an organization context and none is provided, it fails closed immediately before hitting the storage layer.

Layer 2: Postgres Row-Level Security (RLS)
Layer 1 protects the paths that remember to call it. But what about a new repository method added next month? Or an ORM query built dynamically?

This is where the database enforces its own boundary. We run Postgres with Row-Level Security (RLS) on a dedicated, non-superuser application role:

-- Postgres RLS Policy Example
CREATE POLICY tenant_isolation_policy ON invoices
FOR ALL
USING (tenant_id = current_setting('app.current_tenant_id'));
Enter fullscreen mode Exit fullscreen mode

If a developer writes SELECT * FROM invoices without a WHERE clause, the database itself filters the rows based on the session's tenant context. An un-scoped query returns zero rows, not all rows.

Layer 3: Schema Constraints & State Immutability
The deepest layer prevents illegal states from ever being written:

  • Composite Foreign Keys: Prevent a child record's tenant_id from disagreeing with its parent entity.
  • Materialized Path Triggers: Automatically derive an organization's position in the customer hierarchy, like Health System to Hospital to Department, so an org tree node cannot be corrupted by an invalid update.
  • Immutable Audit Trail: The append-only audit log table strictly rejects UPDATE and DELETE operations at the database driver and schema level.

Measuring the Boundary (Not Asserting It)
We do not just assert that tenant boundaries hold on a marketing datasheet.

Our test suite includes an automated 165-check cross-tenant leak matrix. It actively attempts cross-tenant reads, sibling branch traversal, and privilege escalation attacks against real Postgres databases (not emulated mocks) before any build is tagged for release.

How do you enforce tenancy?
If you are building multi-tenant software, where does your security boundary live today? Is it purely in your ORM or application middleware, or are you enforcing it down at the database layer?

We packaged this 3-layer isolation pattern, along with multi-tenant SSO, audit logging, and official SDKs (Node, Python, PHP, Java) into Latch Vector.

You can read the full architecture details in our public docs: Documentation

Curious to hear how other backend engineering teams handle tenant isolation in their stacks!

Top comments (0)