B2B SaaS goes multi-tenant whether you plan for it or not. Your first customer is one org. Customer ten wants to invite 50 colleagues. Customer fifty wants workspaces that belong to different teams. Here’s an architecture that handles all of this without exotic infrastructure.
The shape
One database. One application. Every tenant is a row in a tenants table. Every non-lookup row has a tenant_id foreign key. Every query filters by tenant.
tenants (id, name, ...)
memberships (id, tenant_id, user_id, role)
users (id, email, ...) -- global; users can be in multiple tenants
posts (id, tenant_id, ...) -- tenant-scoped
comments (id, tenant_id, ...) -- tenant-scoped
That’s the schema shape. Every B2B SaaS can start here.
The auth flow
- User signs in with email + password (or OAuth, SSO, whatever).
- Your auth layer returns the user’s identity.
- You look up
membershipsfor that user — possibly multiple tenants. - The user’s session stores both
user_idandcurrent_tenant_id. - Every authenticated request has access to both.
- Every database query filters by
current_tenant_id.
Tenant switching
Users in multiple tenants need a way to switch. UX patterns:
- Subdomain:
acme.yourapp.comvscontoso.yourapp.com. Clean separation, best for B2B enterprise. - Workspace picker: a dropdown in the app that switches the current tenant. Best for small teams who work across workspaces.
- Both: subdomain default, with a picker for users in multiple tenants under the same subdomain.
Under the hood, switching tenants means updating the session’s current_tenant_id and refreshing the session cookie.
Enforcement: don’t trust application code
Every query needs to filter by tenant. In practice, every engineer eventually writes a query that forgets. That’s a cross-tenant data leak, which is a security bug, which can end a customer relationship.
Defense: Postgres Row-Level Security (RLS). For every table with a tenant_id:
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON posts
USING (tenant_id = current_setting('app.current_tenant')::uuid);
Your request middleware sets the current tenant at the start of each request:
SET app.current_tenant = 'tenant-uuid';
Now any query — through your ORM, a new developer’s raw SQL, a forgotten filter — is automatically scoped. The DB refuses to return rows from other tenants. Defense in depth: application code does the filter, DB enforces it as a fallback.
What not to do (common traps)
Don’t use schema-per-tenant
Separate Postgres schemas per tenant sounds like cleaner isolation. In practice, it creates migration nightmares (applying the same migration to 500 schemas), operational overhead, and rarely provides the isolation benefit in question. Use it only if a specific compliance requirement demands it.
Don’t use database-per-tenant
Same problems, worse. Don’t start here.
Don’t let clients pass tenant_id in requests
// WRONG
app.get('/posts', (req) => {
return db.query('SELECT * FROM posts WHERE tenant_id = ?', [req.query.tenant])
})
Clients can change req.query.tenant to any value. Derive the tenant from the session, always:
// RIGHT
app.get('/posts', (req) => {
return db.query('SELECT * FROM posts WHERE tenant_id = ?', [req.session.tenant_id])
})
When to graduate
The one-DB, RLS-based approach scales to hundreds of tenants and millions of rows per tenant comfortably. You start hitting limits when:
- A single tenant is 10× the size of others and disrupts shared-pool queries
- A specific compliance requirement needs physical isolation
- You’re selling a self-hosted option
At that point, schema-per-tenant or DB-per-tenant becomes necessary. But 95% of B2B SaaS never reaches that point. The simple model wins.