Home/Blog

Multi-Tenant SaaS Architecture: Patterns That Scale

Multi-tenant SaaS architecture in practice: pool, bridge and silo models, Postgres row-level security, tenant routing, noisy neighbors, SSO and billing.

Multi-Tenant SaaS Architecture: Patterns That Scale

Most SaaS products should start with a pooled model: one shared database, a tenant_id column on every tenant-owned table, and PostgreSQL row-level security enforcing isolation underneath the application code. Resolve the tenant once per request from the authenticated session, carry it through every layer (queries, caches, queues, storage paths, logs), and keep a tenant catalog that can route any tenant to a dedicated database or cell when compliance, residency or size demands it. That hybrid, pool most tenants and silo the few that need it, gives you the economics of shared infrastructure without painting you into a corner when the first enterprise customer arrives.

Key takeaways

  • Start pooled, with row-level security as defense in depth. Add silos for specific tenants, not for everyone.
  • The tenant comes from the authenticated session, never from a request body, header or query parameter the client controls.
  • Tenant context must travel everywhere: database sessions, cache keys, job payloads, object storage prefixes, search filters and logs.
  • A tenant catalog (which tenant lives on which shard, region and plan) is the piece that lets you move tenants later without rewriting the app.
  • Isolation is a test suite, not a code review habit: automated cross-tenant access tests on every endpoint catch the leak before a customer does.

Tenancy models: pool, bridge and silo

The industry vocabulary, used throughout AWS's SaaS guidance, is pool, silo and bridge.

ModelWhat is sharedIsolation comes fromCost per tenantOperational loadGood for
Pool: shared schemaEverything, rows tagged with tenant_idApplication code plus row-level securityLowestLowestSelf-serve and SMB tenants, most products
Bridge: schema per tenantDatabase server, separate schemasSchema boundaries and grantsMediumMigrations run once per schemaModerate isolation needs, modest tenant counts
Silo: database (or stack) per tenantLittle or nothingInfrastructure boundariesHighestProvisioning, migrations and monitoring per tenantRegulated, high-value or very large tenants

Schema-per-tenant looks like a comfortable middle, but it ages badly: every migration runs once per schema, the system catalog grows with every tenant, and connection pools fragment. If you need stronger isolation than pooling gives, a dedicated database for the specific tenants that need it is usually cleaner than schemas for everyone.

The practical answer is hybrid: pool by default, silo by exception, decided per tenant in a catalog.

Loading diagram...

Isolation in the database: row-level security done right

Application-level filtering (WHERE tenant_id = $1 on every query, or an ORM extension that adds it) is necessary and not sufficient. One raw SQL report, one forgotten join, and you have a cross-tenant leak. PostgreSQL row-level security puts the filter where it cannot be forgotten:

CREATE TABLE invoices (
  tenant_id  uuid        NOT NULL REFERENCES tenants (id),
  id         uuid        NOT NULL DEFAULT gen_random_uuid(),
  project_id uuid        NOT NULL,
  amount     numeric(12,2) NOT NULL,
  status     text        NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (tenant_id, id),
  FOREIGN KEY (tenant_id, project_id) REFERENCES projects (tenant_id, id)
);
 
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;
 
CREATE POLICY tenant_isolation ON invoices
  USING (tenant_id = current_setting('app.tenant_id', true)::uuid)
  WITH CHECK (tenant_id = current_setting('app.tenant_id', true)::uuid);

Each line earns its place:

  • tenant_id leads the primary key and indexes. Every query filters by tenant, so the index should start there.
  • Composite foreign keys (tenant_id, project_id) make it impossible for an invoice in tenant A to reference a project in tenant B, even through a bug. projects needs a unique key on (tenant_id, id) for this to work.
  • FORCE ROW LEVEL SECURITY applies the policy to the table owner too. Without it, an app that connects as the owner bypasses every policy silently.
  • current_setting(..., true) returns null when the setting is missing, so a request that forgot to set tenant context sees no rows instead of all rows. It fails closed.
  • WITH CHECK stops writes into another tenant, not just reads.

The app must connect as a role that does not own the tables and does not have BYPASSRLS; superusers and owners are exactly the roles RLS does not constrain by default. Run migrations as the owner, serve traffic as a restricted role.

Setting the tenant per request has one sharp edge. With a connection pooler such as PgBouncer in transaction mode, a session-level SET leaks to whichever client gets that connection next. Set it per transaction:

import type { Pool, PoolClient } from "pg";
 
export async function withTenant<T>(
  pool: Pool,
  tenantId: string,
  fn: (db: PoolClient) => Promise<T>,
): Promise<T> {
  const client = await pool.connect();
  try {
    await client.query("BEGIN");
    // Third argument true = local to this transaction, so it cannot leak to the next user of the connection.
    await client.query("SELECT set_config('app.tenant_id', $1, true)", [tenantId]);
    const result = await fn(client);
    await client.query("COMMIT");
    return result;
  } catch (err) {
    await client.query("ROLLBACK");
    throw err;
  } finally {
    client.release();
  }
}

For IDs, prefer UUIDs over sequential integers in a pooled model (sequential IDs leak tenant activity and invite enumeration). Time-ordered UUIDv7 keeps index inserts local; PostgreSQL 18 generates them natively with uuidv7(), and on older versions the application can generate them. Our database selection guide covers when Postgres is the right base for this in the first place.

Resolving and propagating tenant context

Decide once, early in the request, which tenant this is, and make everything downstream read that decision instead of re-deriving it.

SourceExampleUse for
Session or token claimorg_id claim in the access tokenThe authority for API requests
Subdomainacme.app.example.comRouting and branding before login
Custom domainportal.acme.comEnterprise branding, mapped in the catalog
Path/orgs/acme/projectsUsers who switch between several organizations

Subdomains and paths say which tenant the user is trying to reach. The session says which tenants they belong to. Both must agree, and a membership check enforces it. Never accept tenant_id from a request body: that is a one-line privilege escalation.

Tenant context then has to cross every boundary:

  • Background jobs carry tenant_id in the payload and call withTenant before touching data. A worker that processes jobs for many tenants on one connection is where context bugs hide.
  • Caches prefix every key with the tenant (t:<tenant_id>:invoice:<id>). A cache key without a tenant is a leak that only shows up under load.
  • Object storage uses tenant prefixes, and pre-signed URLs are generated only after an authorization check on that prefix.
  • Search indexes filter by tenant in every query, enforced in one query-building function rather than in each caller.
  • Logs, metrics and traces carry tenant_id as a structured field, so you can answer "is this one customer or everyone?" in seconds during an incident.

Noisy neighbors and fair sharing

In a pooled system, one tenant's bulk import can become everyone's outage. Put a limit at every shared resource:

Shared resourceControl
APIPer-tenant token bucket rate limits, with higher limits by plan
Job queuesFair scheduling across tenants (per-tenant queues or concurrency caps), so one tenant's 100,000 jobs do not starve everyone else
Databasestatement_timeout for the app role, per-tenant concurrency limits on expensive endpoints, heavy reports on a read replica
Storage and exportsQuotas per plan, large exports as async jobs
Search and analyticsPer-tenant query timeouts and result caps

Measure per-tenant consumption (requests, job time, database time, storage) on a dashboard. That data tells you which tenants to move to dedicated capacity, and it is also the basis for honest per-tenant cost and pricing decisions.

Scaling: shards, cells and the tenant catalog

A single primary database goes a long way with connection pooling, read replicas and good indexes. When it stops being enough, scale by tenant, because tenant is the boundary that almost no query crosses.

The tenant catalog is the small, central table that makes this possible:

CREATE TABLE tenants (
  id          uuid PRIMARY KEY,
  slug        text UNIQUE NOT NULL,
  plan        text NOT NULL,
  region      text NOT NULL,      -- 'us', 'eu'
  cell        text NOT NULL,      -- which stack serves this tenant
  db_shard    text NOT NULL,      -- which database holds its rows
  status      text NOT NULL,      -- 'active', 'suspended', 'migrating', 'deleted'
  created_at  timestamptz NOT NULL DEFAULT now()
);

Routing reads this (cached briefly) and sends each request to the right cell and shard. A cell is a full copy of the stack (app, workers, database) serving a subset of tenants. Cells limit blast radius: a bad deploy or a runaway tenant takes down one cell, not the whole product. They also make data residency straightforward, since an EU cell keeps EU tenants' data in the EU.

Moving a tenant between shards is a planned operation: mark it migrating, copy its rows (every tenant-owned table, which is why tenant_id on every table matters), verify, flip the catalog, then delete the old copy. Practice it before a customer's contract depends on it.

Migrations across many databases need orchestration. Use expand and contract: add new columns and tables in a backward-compatible step, deploy code that works with both shapes, backfill, then remove the old shape in a later release. Run each step across shards with tracking, so a failure on shard 7 of 12 is visible and resumable rather than a half-migrated fleet.

Identity, SSO and authorization

Model identity for multiple organizations from day one:

  • A user can belong to several organizations, with a role in each. The session holds the active organization; switching organizations re-checks membership.
  • Enterprise identity means SAML or OIDC single sign-on configured per organization, plus SCIM provisioning so the customer's identity provider can create and deactivate users. Identity platforms such as Auth0, Clerk or WorkOS handle the protocol work; building SAML yourself is rarely a good use of a small team.
  • Authorization starts with roles inside the tenant (owner, admin, member, viewer). When permissions depend on relationships ("can edit this document because they are on the team that owns the project"), a relationship-based system such as OpenFGA, modeled on Google's Zanzibar, scales better than ever-growing role lists.

Whatever you choose, every authorization check takes the tenant into account. The most common SaaS vulnerability is not a clever exploit; it is an endpoint that loads /invoices/:id without checking that the invoice belongs to the caller's tenant. Our API security guide and IAM strategy guide go deeper on both halves.

Plans, entitlements and billing

Keep three things separate: the billing provider (Stripe or similar) knows what the customer pays; your entitlements layer knows what the tenant may use; feature flags control rollout. Code checks entitlements, never plan names:

type Entitlements = {
  seats: number;
  projects: number;
  features: Set<"sso" | "audit_log" | "api_access" | "advanced_analytics">;
};
 
const PLAN_ENTITLEMENTS: Record<string, Entitlements> = {
  free: { seats: 3, projects: 5, features: new Set() },
  pro: { seats: 25, projects: 50, features: new Set(["api_access", "advanced_analytics"]) },
  enterprise: {
    seats: Number.POSITIVE_INFINITY,
    projects: Number.POSITIVE_INFINITY,
    features: new Set(["sso", "audit_log", "api_access", "advanced_analytics"]),
  },
};
 
export function entitlementsFor(tenant: { plan: string; overrides?: Partial<Entitlements> }): Entitlements {
  return { ...PLAN_ENTITLEMENTS[tenant.plan], ...tenant.overrides };
}
 
// In a handler: check the capability, not the plan.
if (!entitlementsFor(tenant).features.has("audit_log")) throw new ForbiddenError("audit_log");

Per-tenant overrides are how sales closes deals ("50 seats on the pro plan for six months") without code changes. On the billing side, treat provider webhooks as at-least-once and unordered: store each event id, ignore duplicates, and fetch the current subscription state from the provider instead of trusting event order. Record usage in your own database first and report aggregates to the billing provider, so a failed sync never loses billable usage. Our SaaS pricing and monetization guide covers metering and plan design in detail.

Tenant lifecycle

The parts of SaaS architecture that get skipped until a customer asks:

  • Provisioning is an idempotent workflow (catalog row, default roles, initial data, dedicated resources for silo tenants) that can be retried safely from any step.
  • Suspension blocks access without deleting data, for unpaid invoices or security holds.
  • Export gives a tenant all of its data in a documented format. Enterprise contracts ask for it.
  • Deletion removes a tenant's rows from every table, every object storage prefix, every search index and every cache, then records that it happened. Backups age out on their own schedule, so say so in your data processing terms.
  • Single-tenant restore is the one teams discover too late. In a pooled database you cannot restore one tenant from a backup without restoring the whole database to a side instance and copying that tenant's rows back. Script it and rehearse it before you need it.

What goes wrong in production

  • The report that bypassed the ORM. A raw SQL query for an admin export forgets the tenant filter. Row-level security is what turns this from a breach into an empty result.
  • RLS silently off. The app connects as the table owner, or a migration created a new table without enabling RLS. Add a CI check that lists tenant-owned tables without policies, and fail the build.
  • Context leaking across pooled connections. Session-level SET with a transaction-mode pooler hands one tenant's context to another request. Use transaction-local settings only.
  • Jobs with the wrong tenant. A worker reuses context from the previous job. Set context explicitly at the start of each job and clear it at the end.
  • Cache keys without a tenant. Works in development with one tenant, leaks in production with many.
  • One tenant's import locks a shared table. Bulk operations run in batches, off-peak where possible, with per-tenant concurrency limits.
  • Support impersonation without an audit trail. Staff need to see what a customer sees, but every impersonated session must be logged, time-limited and visible to the customer's admins.

The defense that catches most of these is an isolation test suite: create two tenants in CI, then for every endpoint, call it with tenant A's credentials against tenant B's resource IDs and assert a 404. Run it on every pull request.

Frequently Asked Questions

What is the best multi-tenant database model for SaaS?

For most SaaS products, a shared database with a tenant_id column on every tenant-owned table is the right start, protected by row-level security. It is the cheapest to run and the simplest to migrate. Move individual tenants to dedicated databases when compliance, data residency or sheer size requires it, rather than starting everyone there.

How do you prevent data leaks between tenants?

Use layers. Derive the tenant from the authenticated session, never from request input; set it as context once per request; enforce it in the database with row-level security; and include it in every cache key, storage path and search query. Then test isolation automatically by having one tenant's credentials try to read another tenant's resources on every endpoint.

What is the difference between silo and pool tenancy?

In a pool model, tenants share infrastructure such as databases and services, and isolation is enforced by code and policies. In a silo model, each tenant gets dedicated resources, so isolation comes from the infrastructure itself. A bridge or hybrid model mixes the two, usually pooling small tenants and siloing large or regulated ones.

How do you handle noisy neighbors in a multi-tenant system?

Limit what any one tenant can consume at every shared resource: per-tenant API rate limits, fair scheduling in job queues, statement timeouts in the database, and quotas on storage and exports. Watch per-tenant resource usage so you see a heavy tenant before other tenants feel it. Tenants that consistently need more get moved to their own shard or cell.

Should SSO be a paid feature in a SaaS product?

Many SaaS companies put SAML or OIDC single sign-on in their enterprise tier, because enterprise buyers require it and it carries real support cost. Whatever you charge, design the identity model for it from the start: users who can belong to several organizations, and per-organization identity provider settings. Retrofitting that later is far more expensive than building it in.

Need help with your SaaS architecture?

We build multi-tenant systems and then operate them, which is why isolation tests, the tenant catalog and the single-tenant restore script get built before the features that demo well. If you are designing a new SaaS platform, preparing for your first enterprise customer, or worried about what a cross-tenant bug would cost you, talk to the senior engineer who would do the work.