Articles

One database, a thousand customers, zero leaks

How multi-tenant systems keep customers apart: the three tenancy models, what Slack, Notion, Shopify, Figma, Salesforce and Atlassian actually run, the eight places where tenant isolation breaks, and what Postgres row-level security does and does not guarantee. Every figure is sourced.

Four tenant cards stacked, one highlighted, each marked isolated

Every SaaS product makes the same promise: your data sits next to other customers' data, and they will never see it. This article explains how that promise is kept, where it breaks, and what the largest products on the internet do about it.

Version française : Multi-tenant : une base, mille clients, zéro fuite.

Two leaks to keep in mind

In September 2026, a security researcher started a container on Cloudflare and read what the previous occupant had left on disk. The storage pool handed out 64 KiB blocks without clearing them, so a 4 KiB write claimed a block that still held up to 60 KiB of someone else's data: directory listings, database pages, and complete SQLite databases from other customers. Residual data showed up on 18 of the 24 placements tested, across 20 of 22 hosts on four continents. Cloudflare fixed it within three days and found no sign of abuse (Cloudflare's own write-up, September 2026).

In March 2023, ChatGPT users saw conversation titles that were not theirs. The cause was a bug in redis-py, the Redis client library: when a request was cancelled after being queued but before its response was read, the connection was left corrupted and the next response on it went to an unrelated request. OpenAI also found that the same bug may have exposed, for 1.2% of Plus subscribers active in a nine-hour window, another user's name, email, billing address, card type, last four digits and expiry date (OpenAI post-incident report).

Neither leak came from a WHERE clause that forgot a tenant. One was the storage layer, the other a connection pool. Keep both in mind: by the end of this article you will know which parts of a tenancy design would have caught them, and which would not.

What "multi-tenant" means

A tenant is a customer: a company, a workspace, a shop. A multi-tenant system serves many tenants from one deployment and, usually, one database. The alternative, one deployment per customer, is how enterprise software was sold for decades, and it is still how some of it is sold today.

The whole subject fits in one sentence: one tenant, one boundary. Every design question is about where that boundary lives (infrastructure, database, schema, row, application code), which layer enforces it, and who can bypass that layer.

The three models

The industry has settled on names. AWS calls them silo, bridge and pool (SaaS Tenant Isolation Strategies). Microsoft's guide for Azure SQL uses standalone app, database-per-tenant and sharded multitenant (Multitenant SaaS patterns). They describe the same three shapes.

The three tenancy models: silo, bridge, pool

Silo: one database per tenant. The strongest isolation you can buy with a database engine. Restoring one customer to yesterday at 14:00 means restoring one database. Moving a customer to a region of their choice means moving one database. The price is operational: every migration runs once per tenant, every index exists once per tenant, and a small tenant costs as much to run as a large one unless the platform pools resources. Microsoft's own example: a thousand tenants with twenty indexes each is twenty thousand indexes to manage.

Bridge: one schema per tenant, in a shared database. The tables are duplicated per tenant inside one database, which gives a clean namespace without the cost of a full database. It looks like the best of both worlds. The next section explains why teams who ran it at scale walked away from it.

Pool: shared tables, one tenant column on every row. All tenants' rows live in the same tables, told apart by a tenant_id. Cheapest per tenant, simplest to migrate, and the model that scales most practically to millions of small tenants in shared tables. The cost is that no physical boundary separates tenants: isolation has to be an explicit policy, enforced in the application, in the database, or ideally both. Microsoft's comparison table says it plainly: tenant isolation is "low" in the sharded multitenant model, and the database must be monitored as a whole because the platform cannot see which tenant is consuming what.

The same table puts scale at "1 to 100s" of tenants for standalone, "1 to 100,000s" for database-per-tenant and "1 to 1,000,000s" for sharded multitenant. Nobody serves a million customers with a million databases. Microsoft also documents a hybrid: every database carries the tenant column and can hold many tenants, but some hold exactly one, so a free-trial tenant can live in a shared database and a premium one can be moved to a database of its own without a schema change. The choice is not permanent, and it does not have to be the same for every customer.

What the large products actually run

I read the engineering posts of six companies to answer one question: at scale, which model survives? The answer is the pool, sharded, with a routing layer that knows where each tenant lives.

Slack started as a LAMP application with MySQL sharded by workspace: every workspace's data lived on one shard, and a metadata cluster mapped workspace to shard. By fall 2016 the fleet handled hundreds of thousands of queries per second across thousands of hosts, and the model was cracking. Large customers filled the biggest hardware available. A few enterprise teams created hot spots while most of the fleet sat idle. Enterprise Grid and Slack Connect, where a channel is shared between workspaces, broke the assumption that a team's data sits on one shard. Slack moved to Vitess between 2017 and the end of 2020, resharding messages by channel ID rather than by workspace. The post reports 2.3 million queries per second at peak, a median latency of 2 ms and a p99 of 11 ms, and Vitess running in six regions for data residency (Scaling Datastores at Slack with Vitess).

Slack, official product page, screenshot taken in October 2026

Slack, screenshot of slack.com taken in October 2026. The engineering details above come from the linked post, not from this page.

Notion sharded Postgres by workspace_id in 2021, because every block belongs to exactly one workspace and users work in one workspace at a time. They chose 480 logical shards spread over 32 physical databases, fifteen per machine, each logical shard being a Postgres schema with its own copy of the sharded tables. Why 480? Because "480 is divisible by a lot of numbers", so hosts can grow in even steps instead of doubling. The trigger was not storage but VACUUM stalling and the risk of transaction ID wraparound (Herding elephants). In 2023 they went from 32 to 96 machines with zero observed downtime, using logical replication, dark reads and a per-database failover rehearsed in staging. Peak CPU and IOPS fell from above 90% to around 20% (The Great Re-shard). Note what the schemas are here: shards, not tenants. Fifteen schemas per machine, later five, each holding thousands of workspaces.

Notion, official product page, screenshot taken in October 2026

Notion, screenshot of www.notion.com/product taken in October 2026. The engineering details above come from the linked post, not from this page.

Shopify runs pods. "A pod consists of a set of shops that live on a fully isolated set of datastores", and a component called Sorting Hat in the load balancers tags every request with its pod, so a request or a background job only ever touches one pod (A Pods Architecture). Inside, the sharding key is shop_id, present on every shop-owned table. Moving a shop between shards is a routine operation: Ghostferry copies the shop's rows in batches while tailing the MySQL binlog, then a short cutover under a Redis lock switches the routing table. Before rebalancing, database usage varied by almost 4x between shards; after, about 2x (Shard balancing). At SREcon 2019, two Shopify engineers described moving the full dataset of more than 800,000 tenants to the cloud with this tooling, online (USENIX).

Shopify, official product page, screenshot taken in October 2026

Shopify, screenshot of www.shopify.com taken in October 2026. The engineering details above come from the linked post, not from this page.

Figma grew its Postgres footprint almost 100x between 2020 and 2024, from one database on the largest RDS instance to a dozen vertically partitioned databases, and then to horizontal sharding. It took about nine months to ship the first horizontally sharded table, in September 2023. Two ideas carry the design. "Colos" are groups of tables that share a sharding key and a physical layout, so joins and transactions work as long as they stay within one key. DBProxy is a Go service that parses every query, works out which shards it touches, and either routes it to one shard or fans it out and aggregates; the team chose a query language that covers the most common 90% of queries and refused the rest. Keys are UserID, FileID and OrgID, hashed to avoid hot spots (How Figma's databases team lived to tell the scale).

Figma, official product page, screenshot taken in October 2026

Figma, screenshot of www.figma.com taken in October 2026. The engineering details above come from the linked post, not from this page.

Salesforce is the oldest and strangest. Force.com does not create tables for customers at all. Metadata tables (Objects, Fields) describe each organization's objects, and all application data lands in one large Data table whose rows carry a GUID, an OrgID and an ObjID, followed by flex columns Value0 to Value500, all of a variable-length string type, so that any field of any type can be stored in any slot. Indexes, uniqueness and relationships are handled by pivot tables, and the whole thing is physically partitioned by OrgID. The query optimizer keeps per-tenant statistics, because, as the paper puts it, conventional optimizer statistics "fail to account for the data access characteristics of any given user executing a query in a multitenant environment" (The Force.com Multitenant Architecture, whitepaper).

Salesforce, official product page, screenshot taken in October 2026

Salesforce, screenshot of www.salesforce.com taken in October 2026. The engineering details above come from the linked post, not from this page.

Atlassian says on its trust page that "each shard contains the data for multiple tenants, but each tenant's data is isolated and inaccessible to other tenants", that it chose to isolate microservices and data "rather than providing dedicated per-tenant infrastructure", and that every data access in Jira and Confluence requires a tenant context delivered by a Tenant Context Service. Data residency is offered across 11 regions (Cloud architecture and operational practices). The Jira platform team adds that customers are divided into deployments, each copy of a service handling a subset of customers, and that the permissions cache is namespaced by tenant (How we unlocked performance at scale).

Jira, official product page, screenshot taken in October 2026

Jira, screenshot of www.atlassian.com/software/jira taken in October 2026. The engineering details above come from the linked post, not from this page.

Six companies, one pattern: a tenant key on every row, a map from tenant to shard, and tooling to move a tenant without downtime. The silo exists at this scale only as a product tier: Atlassian sells it as Isolated Cloud.

Why not a schema per tenant

The bridge model is where many Rails and Django teams start, and Influitive, the company behind the Apartment gem, wrote the post-mortem. With one schema per customer, a column addition that should be O(1) becomes O(N) in the number of tenants, and an index addition is "a hell of a lot worse". Every Ruby process grew to about 500 MB of memory the moment it connected, because every schema's copy of the same tables is loaded by the client. Each schema has its own sequences, so every tenant has a user with id = 1, and any cross-tenant report or export breaks. Their conclusion: "mostly abandon our separate schemas approach" and move new services to a tenant column (Our multi-tenancy journey with Postgres schemas and Apartment).

Citus added schema-based sharding in version 12 (July 2023), where each schema becomes a logical shard placed on one node, and its own announcement names the trade-off: "the need to manage many tables and performance overhead" (Citus 12). It fits a small number of large tenants, or a product retrofitting tenancy onto an existing schema. It does not fit thousands of small ones.

Why not a database per tenant, and when you should

The silo has had a revival, driven by platforms that make databases cheap. Azure SQL manages "well over 100,000 databases" with automatic indexing and per-database restore (Microsoft). Cloudflare D1 allows 50,000 databases per account on a paid plan, caps each at 10 GB, and its own documentation recommends "per-user, per-tenant or per-entity databases" (D1 limits). Turso built its product around the same idea on SQLite.

When it is the right choice: few tenants with high value, a regulator who wants a physical boundary, data residency promised per customer, or a tenant who needs their own point-in-time restore. The costs do not go away: no cross-tenant query without a separate analytics pipeline, one schema migration per database, and, on SQLite-based platforms, one writer per database.

The eight places where tenant isolation breaks

Now the part the title promised. In the pool model, isolation is not a property of the database. It is a property of every path that touches the database, and of a few things the database can enforce for you if you ask. Here is the path of one request, and the badge it has to carry at every step.

One request, and the badge it carries everywhere

I spent two years on Ever Gauzy, an open source business platform built with NestJS, Angular and PostgreSQL, where every entity extends a TenantBaseEntity with a tenantId column and every service extends a TenantAwareCrudService that merges the tenant from the request context into every filter. Most of what follows I learned by fixing it.

The eight places where tenant isolation breaks

1. The query that forgot the tenant. The classic. One endpoint, written on a Friday, queries by primary key without the tenant filter, and any customer who guesses an ID reads another customer's record. The fix is structural, not a code review checklist: put the tenant filter in one base class that every read and write goes through, and make the raw repository hard to reach. In Gauzy, that base class reads the tenant from a request context stored in continuation-local storage, so the service does not need the caller to pass it.

2. The relation loaded without a filter. The query is scoped, but it eager-loads project.members, and the members table is not filtered. Or a nested filter such as where: { project: { id } } lets the caller pick a project they do not own. I fixed two of these in Gauzy (#8961, #9014), by filtering relations through a declared permission map rather than trusting the shape of the request. Relations cross boundaries silently; treat each one as a query of its own.

3. The foreign key that crosses tenants. Reads can be perfectly filtered and the data still wrong. If orders references customers by customer_id alone, nothing stops a row of tenant A from pointing at a customer of tenant B: the reference is valid for the database, and it was created by a bug or by a crafted request. The fix is a composite foreign key, (tenant_id, customer_id) referencing customers (tenant_id, id), so that the database itself refuses a cross-tenant reference. It costs a composite unique key on the parent table and nothing else. Citus asks for the same shape between co-located distributed tables: "always include the distribution column in the key" (Citus, modifying tables).

4. The cache key without a tenant. user:42 is a fine key until two tenants both have a user 42. Prefix every key with the tenant, and when you cache a whole response, make the tenant part of what identifies the response. Atlassian's Jira platform namespaces its permission cache by tenant for exactly this reason. The OpenAI incident sits one layer below the key: the bug returned the wrong connection's bytes, so no key scheme would have helped. The defence there is to verify the payload on the way out, which I come back to at the end.

5. The background job without context. Request-scoped context dies with the request. A job picked up by a worker has no tenant unless the payload carries it and the worker rebuilds the context before touching the database. The telling symptom: a cron that "cleans up expired items" across all tenants at once, which is a cross-tenant query by design, written by someone who forgot it was.

6. The search index without a filter. Elasticsearch, Meilisearch, pgvector: each is a second datastore with its own notion of rows. Either every document carries the tenant and every query filters on it, or there is one index per tenant. Atlassian's issue search index is sharded by tenant and project.

7. The pooled connection. This one is specific to Postgres and poolers, and it is the subtlest. The usual way to pass the tenant to row-level security is a session variable, SET app.tenant_id = '42'. PgBouncer in transaction mode hands a server connection to a client "only during a transaction", and its documentation marks SET and RESET as never working in that mode (PgBouncer features). A plain SET survives the commit, follows the connection back into the pool, and the next client gets tenant 42's variable. The fix is in the Postgres manual: "The effects of SET LOCAL last only till the end of the current transaction, whether committed or not" (SET). Always SET LOCAL, always inside an explicit transaction. And the value you set must come from the authenticated identity, never from a request body, a query string or a header. A policy enforces whatever tenant the application hands it; hand it the wrong one and RLS applies the wrong one faithfully.

8. The shared backup. This one is not a confidentiality leak but an isolation failure, and it belongs on the list. The Atlassian outage of April 2022 is the reference case. A script meant to delete a legacy app was run with site IDs instead of app IDs, and the API accepted both without checking. Between 07:38 and 08:01 UTC it deleted 883 sites belonging to 775 customers. The data was in the backups. The problem was that Atlassian had no way to restore a subset of tenants from stores shared by many customers; the first restoration method took about 70 steps and 48 hours per batch, the second about 12 hours per site, and the last customers came back on April 18 (Post-incident review). In the pool model, "we have backups" is not the same as "we can restore one customer". The second has to be built and rehearsed.

Where a request context breaks in NestJS

The base class approach only holds while the tenant is in the context, and in a NestJS application there are four places where it quietly is not.

  • Asynchronous propagation. The context lives in AsyncLocalStorage, through a library such as nestjs-cls. It follows awaited promises and callbacks created inside the request, and it stops at anything that runs on another stack: a timer armed before the request, an event emitter wired at startup, a connection pool that fires callbacks from its own loop. Work started there has no tenant.
  • Scheduled tasks and consumers. A @Cron method, a queue consumer or a WebSocket handler is not a request. In Gauzy, the tenant-aware service documents the consequence in one line: when no user is available in the request context, "it behaves exactly the same as" the plain CRUD service, meaning unscoped. That is the honest behaviour, and it is also exactly the shape of a leak. A worker has to open a context itself, from the tenant carried by the job payload, before touching a repository.
  • Direct repository access. Anything that reaches the TypeORM DataSource or a QueryBuilder directly skips the service and its filter. The base class is only as strong as the rule that keeps raw repositories out of feature modules, which is a code review rule, not a type.
  • Transactions. A transaction opened with dataSource.transaction() hands out its own entity manager. Queries built on it must add the tenant again; nothing inherits it.

None of these is an argument against the base class. They are the reason the database needs its own filter.

Row-level security: exactly what Postgres promises

Row-level security (RLS) lets the database enforce the tenant filter, so a query that forgets it returns nothing instead of everything. It is the right second line. It is not a first line, and the manual is precise about its limits (Row Security Policies):

  • "Superusers and roles with the BYPASSRLS attribute always bypass the row security system when accessing a table." So the application must connect as a role that is neither, and migrations run as a different role.
  • "Table owners normally bypass row security as well", unless the table is altered with FORCE ROW LEVEL SECURITY. If your app role owns the tables, RLS does nothing for it.
  • With RLS enabled and no policy, "a default-deny policy is used". Enabling it is safe by default; forgetting a policy makes a table disappear rather than leak.
  • Policy expressions are evaluated "prior to any conditions or functions coming from the user's query". The only exception is functions marked leakproof, which are guaranteed not to leak information through their arguments or their errors; the planner may run those before the check. An ordinary function, including one you wrote, runs after the filter and never sees a hidden row. The rule exists precisely to close the error-message channel.
  • Referential integrity checks "always bypass row security", and the manual warns about "covert channel" leaks through foreign keys: an insert that references a row the policy hides still succeeds, which tells the attacker that the row exists.

Supabase, which puts RLS in front of every customer, publishes the operational rules that make it fast and safe: a btree index on every column a policy filters on, helper calls wrapped as (select auth.uid()) so Postgres evaluates them once per statement rather than once per row, security_invoker = true on views so they do not bypass the tables' policies, search_path = '' in security definer functions, and a test suite that checks the deny case for every role and every operation, not only the allow case (Supabase RLS guide). The service role key bypasses RLS and never leaves the server.

The combination that holds up: the application filters by tenant in one place, RLS filters again with SET LOCAL inside a transaction, and a negative test suite runs as the application role and asserts that another tenant's rows come back as zero rows.

Here is the whole thing, small enough to run in psql. The table carries the tenant in its primary key, the policy filters reads with USING and writes with WITH CHECK, the owner is filtered too, and the application role owns nothing.

tenant_isolation.sql
CREATE TABLE orders (
  tenant_id uuid    NOT NULL,
  id        uuid    NOT NULL DEFAULT gen_random_uuid(),
  total     numeric NOT NULL,
  PRIMARY KEY (tenant_id, id)
);
 
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders FORCE ROW LEVEL SECURITY;   -- the table owner is filtered too
 
-- current_setting(..., true) returns NULL when nothing was set: default deny.
CREATE POLICY orders_by_tenant ON orders
  USING      (tenant_id = current_setting('app.tenant_id', true)::uuid)
  WITH CHECK (tenant_id = current_setting('app.tenant_id', true)::uuid);
 
CREATE ROLE app_user LOGIN NOBYPASSRLS;        -- owns nothing, cannot bypass
GRANT SELECT, INSERT, UPDATE, DELETE ON orders TO app_user;

And the two tests, run as app_user, with tenant A set from the verified session. The first proves A cannot change B's row: the update matches nothing. The second proves A cannot write a row into B's tenant: WITH CHECK rejects it.

two_tests.sql
BEGIN;
SET LOCAL app.tenant_id = '11111111-1111-1111-1111-111111111111';   -- tenant A
 
UPDATE orders SET total = 0
 WHERE id = '<an order id that belongs to tenant B>';
-- UPDATE 0: the row is invisible to A, so there is nothing to update
 
INSERT INTO orders (tenant_id, total)
VALUES ('22222222-2222-2222-2222-222222222222', 10);                 -- tenant B
-- ERROR:  new row violates row-level security policy for table "orders"
COMMIT;

Put both in the test suite, run them as the application role through the same pooler as production, and the policy is tested the way it will be used.

The noisy neighbour

Isolation is also about performance. In a pool, one tenant's batch import competes for the same CPU, the same IOPS and the same connection slots as everyone else's checkout. Shopify measured a 4x spread in database usage between shards before rebalancing. Notion's 2023 re-shard was triggered by some shards passing 90% CPU at peak while others idled, and spreading the load across three times more machines exposed "outlier" workspaces that consumed a disproportionate share. Atlassian divides customers into deployments so that an incident stays within one shard.

The lesson is operational: the ability to move a tenant between shards is not a scaling feature, it is how you isolate a heavy customer on a Tuesday afternoon. Shopify's Ghostferry, Vitess resharding and Microsoft's split/merge tool exist for that reason. Microsoft's tool has one requirement worth adopting from day one: the tenant identifier must be the leading element of the primary key of every sharded table, so that a tenant's rows can be found and moved quickly.

If I started a SaaS today

  1. Pool model, a tenant_id on every tenant-owned row, and composite foreign keys that carry it. Put the tenant first in the primary key of the tables you may shard one day, which is what Microsoft's split/merge tool requires; global tables such as countries or currencies do not need it. It costs little on day one and keeps every later move possible: sharding by tenant, moving a tenant, promoting a tenant to its own database.
  2. One scoping layer that every query passes through, fed by a request context, with the raw repository out of reach. Relations and nested filters go through it too.
  3. RLS as the second line, with an application role that owns nothing and cannot bypass, SET LOCAL in a transaction, and indexes on every policy column.
  4. The tenant in every key, payload and filter: cache keys, queue messages, search documents, object storage paths, log lines.
  5. A negative test suite that runs as the application role and proves another tenant's rows are invisible, for every operation.
  6. A rehearsed single-tenant restore, before the first customer asks for it.
  7. A plan for the day a customer needs a silo: a region, a dedicated database, a tier. Shopify moves a shop; Atlassian sells Isolated Cloud; you need an answer too.

Back to the two leaks

Would this plan have caught them? Cloudflare's leak was below every item on the list: a storage pool reusing blocks without zeroing them. No tenant column, no RLS and no cache key reaches that layer. Wiz, the firm behind the ChaosDB and ExtraReplica findings, built a framework for exactly this question. Its root cause for the whole family is "improperly implemented security boundaries", and one of the first questions in its isolation review is whether a customer-facing interface is shared or duplicated per tenant (PEACH). A shared block pool is a shared interface, and it was trusted.

OpenAI's leak sat in the connection pool, one layer below the cache key. The only application-level defence that would have worked is the one most systems skip: when a record comes back from a cache or a client library, check that its tenant matches the tenant of the request before using it. OpenAI did exactly that after the incident; its report lists "redundant checks to ensure the data returned by our Redis cache matches the requesting user" among the actions taken. It is cheap, it catches the common case where a whole record comes back for the wrong user, and it turns that leak into an error. It is not a guarantee against every way a client library can corrupt data, only against the one that leaks.

That is the honest version of "zero leaks": every layer filters on the way in, and the application checks on the way out, because one of the layers is always someone else's code.

Sources

Multi-tenant : une base, mille clients, zéro fuite

architecture / postgres / saas
28 min de lecture

API REST avec Prisma, PostgreSQL et Express

javascript / express / prisma
16 min de lecture