SaaS & Product
Building a Multi-Tenant SaaS: Architecture Decisions That Matter
Building a Multi-Tenant SaaS: Architecture Decisions That Matter
Multi-tenancy is the foundational architectural decision for any SaaS product. Get it right and you have a system that scales horizontally, isolates tenant data reliably, and lets you ship new features without rebuilding the data model. Get it wrong and you spend the next 18 months firefighting data leaks, performance regressions, and impossible migrations.
Here is what we learned building WebomAI — a platform that spans CRM, cybersecurity, network marketing, website building, SEO, and marketing automation, all on shared infrastructure, all with strict tenant isolation.
The Three Multi-Tenancy Models
Before choosing an approach, understand the trade-offs of each model clearly.
Separate Databases Per Tenant
Maximum isolation. Every tenant gets their own database instance. Data leakage between tenants is structurally impossible — the databases are different servers.
The cost: operational overhead that scales linearly with tenant count. Running migrations means running them against every database, sequentially or in parallel. Monitoring means monitoring every instance. Backups mean backing up every instance. At 10 tenants, this is manageable. At 500, it requires dedicated tooling or a full-time infrastructure team.
Best for: Enterprise deals where clients contractually require data isolation, or healthcare and financial services where regulations mandate physical separation.
Separate Schemas Per Tenant
One database cluster, one schema per tenant. Better operational story than separate databases — you write one migration and apply it to N schemas.
The problem compounds at scale. Schema migrations on PostgreSQL require locks. At 500+ tenants, running ALTER TABLE statements against 500 schemas can take hours. More practically, the tooling for schema-per-tenant multi-tenancy is less mature, and frameworks do not natively support it. You end up writing and maintaining more infrastructure code.
Best for: Small numbers of large tenants (under 50) where schema-level isolation is a genuine product requirement.
Shared Schema with Row-Level Tenant ID
All tenants share the same tables. Every row has an organization_id column. Row-level security at the database layer enforces isolation. Migrations run once against one schema.
This is the right model for the vast majority of B2B SaaS products, and it is the model WebomAI uses. The operational story is clean, the performance characteristics are predictable, and the tooling ecosystem is deep.
The critical caveat: this model requires that tenant isolation be enforced at the database layer, not just at the application layer.
Why Application-Layer Isolation Is Dangerous
The most common implementation mistake with shared-schema multi-tenancy is trusting the application to always include the tenant filter. Every database query includes a WHERE clause filtering by organization_id. Every ORM call filters by the tenant. The entire isolation model depends on developers never forgetting that filter.
This works — until a developer forgets the WHERE clause. Or adds a new query path that bypasses the standard middleware. Or runs a migration script that does not set the tenant context. In any of these cases, one tenant's query can return another tenant's data.
The worst-case failure mode of application-layer-only isolation is a data leak. A tenant sees another tenant's sensitive records. In B2B SaaS, this is a career-ending incident.
Row-Level Security: Database-Enforced Isolation
PostgreSQL's Row-Level Security moves the isolation guarantee to the database itself. Once enabled, the database enforces the isolation policy on every query — regardless of what SQL the application sends.
The mechanism: when RLS is enabled on a table, every query is automatically filtered by the active policy. The policy checks whether the row's organization_id matches the current session's organisation context, which the application sets at the start of each request.
With this in place, a query that accidentally omits the tenant filter does not return all tenants' data — it returns zero rows. The worst-case failure mode becomes a data gap, not a data leak.
The application sets the session variable before running any query, and every subsequent query in that database transaction inherits the correct organisation scope automatically. No application-level WHERE clause is needed.
The JWT as Your Identity Layer
In WebomAI, every authenticated request carries a Supabase JWT. This JWT contains not just the user ID, but the organization_id, the user's role within that organisation, and their feature entitlements.
The backend extracts these claims on every request and sets the database session context before any query runs. This means the RLS policies enforce the correct tenant scope automatically — the organisation identifier flows from the JWT directly into the database session without any additional lookup.
The backend middleware flow on every authenticated request:
- Validate the JWT signature against the Supabase public keys
- Extract organization_id from the JWT claims
- Set the app.organization_id session variable in the database connection
- RLS enforces isolation for all subsequent queries in this transaction
There is no application-level WHERE clause. The database handles it.
Every Table Gets the Same Base Columns
One of the most important conventions across the entire WebomAI codebase: every table that holds organisation-scoped data must have these columns — organization_id (NOT NULL, foreign key to organisations), created_by (foreign key to auth users), created_at (timestamp, default now), and updated_at (timestamp, default now). RLS is enabled on every such table at creation.
Tables added without organization_id become technical debt immediately. Retrofitting the column onto a 10-million-row table — with RLS policies, existing data, foreign key constraints, and live traffic — is a painful experience. This convention is non-negotiable.
The Audit Log Is Not Optional
Every write operation that touches organisation-scoped data must create a row in the audit_logs table, recording: which organisation, which user performed the action, what action (create, update, delete), which table and record, the old data (as JSON), the new data (as JSON), the IP address, and the timestamp.
This is not just good practice — it is the foundation of:
- SOC 2 compliance: Auditors require a complete log of who changed what and when
- Customer-facing activity logs: "Who deleted this contact?" becomes self-service instead of a support ticket
- Incident response: When something goes wrong, the audit log is how you reconstruct exactly what happened
- Accidental deletion recovery: With the old data stored as JSON, you can reconstruct any deleted record
Build the audit log from day one. Adding it retroactively means building a migration that reconstructs historical context you never captured — which is effectively impossible.
Separate Database Per Product, Shared Auth
Our specific architectural choice for WebomAI: the main Supabase project handles authentication and organisation management for the entire platform. Every individual product — CRM Hub, Shield, Network Marketing, SEO, Website Builder — gets its own isolated Supabase project.
This provides data isolation between products. A compromise in the CRM database does not expose cybersecurity data. But it avoids the complexity of a completely separate auth system per product.
Every product's backend validates incoming JWTs against the main Supabase public keys. The organization_id in the JWT is trusted without a database lookup in each product's own database. The main Supabase project is the single source of truth for tenant identity.
Performance Considerations at Scale
One concern with shared-schema multi-tenancy is table size. As tenant count grows, tables grow. A contacts table with 1,000 organisations averaging 10,000 contacts each is a 10-million-row table.
At this scale, three things matter most: partition tables by organization_id so queries only scan the relevant partition; index organization_id on every table — make it part of the table creation template so it never gets missed; monitor per-tenant query times and alert before a customer reports slowness.
What We Would Do Differently
The one decision worth revisiting: starting with Supabase managed PostgreSQL for everything was the right choice for development speed. But Supabase's managed environment constrains some advanced capabilities — fine-grained real-time events via LISTEN/NOTIFY, custom PostgreSQL extensions, advanced partitioning strategies for very high-volume tables.
For a product expecting 10 million or more rows within 18 months, the recommendation is to model your primary database on a managed PostgreSQL instance from day one, and use Supabase for the auth and JWT layer only.
The architecture decisions you make in month one compound — positively or negatively — for years.