When you build a SaaS platform that stores sensitive employee data for multiple companies, the architecture decisions you make on day one echo through every feature, every query, and every security audit for years to come. Multi-tenancy, the practice of serving multiple customers from a single application instance, is one of the most consequential of those decisions. Get it right, and you have a platform that scales efficiently while keeping customer data rigorously isolated. Get it wrong, and you have a data breach waiting to happen.
This article explains how we designed Anthropon's multi-tenant architecture, the tradeoffs we evaluated, and how we tested our isolation guarantees with adversarial attack scenarios.
What Is Multi-Tenancy?
In a multi-tenant application, a single deployment of the software serves multiple customers, called tenants. Each tenant's data is logically isolated from every other tenant's data, even though it may reside in the same database, the same tables, or even the same server process. The alternative, single-tenancy, deploys a separate instance of the application for each customer. Single-tenancy offers strong isolation by default but is expensive to operate and difficult to scale.
Most modern SaaS platforms are multi-tenant because the operational economics are compelling. You maintain one codebase, one deployment pipeline, one set of infrastructure. Updates reach all customers simultaneously. Monitoring is centralized. The challenge is ensuring that tenant A can never see, modify, or infer the existence of tenant B's data.
Three Approaches to Multi-Tenant Data Isolation
There are three common patterns for isolating tenant data in a multi-tenant system, each with distinct tradeoffs:
1. Separate Databases Per Tenant
Each tenant gets their own database instance. This provides the strongest isolation boundary since there is no shared state at all. However, it becomes operationally expensive at scale. Managing hundreds of database instances means hundreds of connection pools, hundreds of backup schedules, and hundreds of migration runs for every schema change. This approach makes sense for enterprise customers with strict data residency requirements, but it does not scale well for a self-serve SaaS platform.
2. Separate Schemas Per Tenant
All tenants share one database instance, but each tenant has their own schema (namespace) within that database. This is a middle ground: it provides logical isolation at the database level without the operational overhead of separate instances. The downside is that schema-per-tenant still requires running migrations independently for each tenant and managing a growing number of database objects. At 1,000 tenants, you have 1,000 copies of every table.
3. Shared Schema with Tenant ID
All tenants share the same database, the same schema, and the same tables. Every row includes a tenant_id column, and every query filters by the authenticated tenant. This is the most operationally efficient approach: one set of tables, one migration path, one backup schedule. It is also the approach that demands the most discipline, because a single missing WHERE clause can expose one tenant's data to another.
Why We Chose Shared Schema
Anthropon uses the shared-schema approach with tenant_id on every data table. We chose this approach for several reasons:
- Operational simplicity: One database to back up, monitor, and tune. One set of migrations that run once and apply to all tenants. This keeps our infrastructure lean and our deployment pipeline fast.
- Self-serve onboarding: When a new company signs up, we create a row in the
tenantstable and seed their initial data. No database provisioning, no schema creation, no connection pool reconfiguration. The entire signup process completes in under two seconds. - Cross-tenant analytics: As a platform operator, we need aggregate metrics: total active users, feature adoption rates, system-wide performance. With shared tables, these queries are straightforward. With isolated databases, they require federation or ETL pipelines.
- Cost efficiency: A single PostgreSQL instance serving 500 tenants costs a fraction of 500 separate instances. For a product targeting small-to-mid-sized companies, this cost structure is essential to offering a competitive free tier.
How Tenant Isolation Works in Practice
Our isolation strategy operates at three layers: authentication, middleware, and data access.
Authentication layer: When a user logs in, the authentication system validates their credentials and issues a JWT that includes both their userId and their tenantId. The tenant ID is embedded in the token at login time and cannot be modified by the client. This means the tenant context is established cryptographically, not through user input or URL parameters that could be tampered with.
Middleware layer: Every API request passes through authentication middleware that extracts and validates the JWT. The tenantId from the token is injected into the request context, making it available to every downstream handler and service. There is no code path that can process a request without an authenticated tenant context.
Data access layer: Every database query that touches tenant data includes a WHERE tenant_id = ? clause using the tenant ID from the request context. This filtering happens in our data access functions, so individual route handlers do not need to remember to add it. The tenant filter is structural, not discretionary.
Why Not Row-Level Security?
PostgreSQL offers Row-Level Security (RLS), a database-native feature that automatically filters rows based on policies. On paper, RLS seems like the ideal solution for multi-tenant isolation because it enforces filtering at the database level, making it impossible for application code to bypass.
We evaluated RLS and decided against it for several practical reasons:
- Session management complexity: RLS policies typically rely on session variables (
SET app.current_tenant) that must be set on every connection before executing queries. With connection pooling, this requires careful lifecycle management to prevent one tenant's session state from leaking to another tenant's connection. - Performance opacity: RLS policies are applied implicitly by the database engine, which can make query plan analysis and performance debugging more difficult. When a query is slow, we want to see the full picture in EXPLAIN ANALYZE without hidden filter predicates.
- Testing and auditability: Application-level filtering is explicit in the code, making it easier to review, test, and audit. Every query's tenant filter is visible in code review, and our test suite can verify its presence directly.
- Migration flexibility: Our ORM (Drizzle) generates type-safe queries with explicit WHERE clauses. Adding RLS on top of this would create two layers of filtering, adding complexity without proportional benefit.
Testing Isolation: 18 Attack Scenarios
Trust in isolation cannot be built on architecture diagrams alone. It must be verified through adversarial testing. We developed a suite of 18 cross-tenant attack tests that systematically attempt to breach isolation boundaries:
- Authenticated requests with a valid JWT attempting to access another tenant's employees, leave records, documents, and workflows
- Requests with manipulated tenant IDs in URL parameters, request bodies, and query strings
- Attempts to create resources in another tenant's namespace
- Attempts to update or delete resources belonging to another tenant
- Enumeration attacks that try to discover the existence of other tenants
Every one of these tests must return a 403 Forbidden or 404 Not Found response. Any other response code is a test failure that blocks deployment. These tests run on every pull request and every deployment, ensuring that isolation guarantees are continuously verified.
Performance Considerations
Adding a tenant_id filter to every query has performance implications. Without proper indexing, these filters can cause full table scans as data grows. Our approach to maintaining performance includes composite indexes that lead with tenant_id on every table, ensuring that the database can efficiently narrow results to a single tenant before applying additional filters. We also scope unique constraints per tenant, for example, employee email addresses must be unique within a tenant but can exist in multiple tenants.
"Multi-tenancy is not just an architecture pattern. It is a commitment to treating every customer's data as if it were the only data in the system. The architecture makes it possible; the testing makes it trustworthy."
Building a secure, performant multi-tenant system requires careful decisions at every layer of the stack. The shared-schema approach gives us the operational efficiency to serve companies of all sizes from a single platform, while application-level isolation, JWT-based tenant context, and continuous adversarial testing give us confidence that every tenant's data remains private. It is a set of tradeoffs we are comfortable with, and one we are committed to verifying on every deployment.