Adding a tenant identifier to every table is a useful start. It does not, by itself, isolate a SaaS application. Data also moves through cache entries, worker messages, search indexes, object storage, and administrative screens.
I approach isolation as a property of the whole request and its later work. At every boundary, the system should know whose data it is handling and why the current operation is allowed to handle it.
Establish tenant context on the server
A tenant ID supplied by the browser is a requested scope, not evidence of membership. The application needs to resolve the authenticated user, verify access to that tenant, and establish an authorization context before loading business data.
That context should be explicit in service boundaries. Hidden global state makes it harder to reason about scheduled tasks and concurrent requests. A worker should receive enough information to resolve its own authorized scope rather than inherit assumptions from the original HTTP request.
Database lookups should reflect that scope. When loading a conversation, I want the query to constrain both its identity and tenant. Fetching a record globally and checking ownership later increases the chance that another code path accidentally uses it first.
Use the database as another boundary
PostgreSQL supports row-level security policies, including default denial when row security is enabled and no applicable policy permits access. Its documentation also makes an important distinction: superusers and roles with bypass privileges are not constrained, and table owners normally bypass the policies.
That means a convincing policy definition is only part of the design. The application’s actual database role, migration role, connection pooling, and transaction behavior all matter. I would verify the policy using the same privileges as the running application.
If tenant context is conveyed through connection settings, pooled connections need particular care. Context must be set through a trusted path, scoped appropriately, and unable to leak into the next request. Row-level security is additional protection, not a substitute for understanding the connection lifecycle.
Include the places outside SQL
A cache key such as conversation:123 assumes the identifier is globally unique and that every caller enforces the same access rules. An explicit tenant component makes the ownership boundary visible and reduces accidental collisions.
Search and retrieval need the same discipline. Tenant and access filters belong in candidate selection. Filtering after a global top-k search can lose valid candidates, and allowing unauthorized text into reranking or model context is already a disclosure.
Object storage paths, export jobs, usage counters, and notification queues should carry tenant context too. An application can secure its interactive screens and still leak data through a background CSV export.
Test with deliberately similar tenants
I like isolation tests that use two tenants with overlapping names, similar documents, and matching external identifiers. This makes accidental global lookups easier to expose than a dataset where every value is conveniently distinct.
- Request another tenant’s record directly by ID.
- Search for wording that appears only in the other tenant’s knowledge base.
- Reuse a cached response after switching authorized workspaces.
- Replay a worker message with mismatched ownership metadata.
- Attempt an export through a role with limited access.
I also test support tooling. Administrative convenience often creates the broadest permissions in the product, so cross-tenant access should be intentional, restricted, and auditable.
A tenant boundary is trustworthy when it survives the ordinary paths and the recovery paths. The question is not only whether the main API is scoped correctly, but whether every later copy and use of the data retains that scope.
Updated 25 September 2026.
