Joseph Leporati

Multi-Tenant Data Isolation: Shared Schema, RLS, or Separate DBs

Short answer

For most multi-client portals and SaaS products, use one shared schema with a tenant_id column on every tenant-owned table, and enforce PostgreSQL row-level security (RLS) under it so the database, not each developer, filters every query by tenant. Choose database-per-tenant only when a contract or regulation requires physical separation. Schema-per-tenant rarely justifies its migration overhead. RLS is only a real backstop if you configure FORCE, WITH CHECK and CI checks correctly.

Abstract layered database cylinder with glowing boundaries keeping each colored layer's data separate

Why tenant data leaks in the first place

Cross-tenant leaks rarely come from exotic attacks. They come from ordinary code: a new report endpoint, a background job or an admin export where someone wrote SELECT * FROM invoices WHERE status = 'open' and forgot AND tenant_id = $1. The query works, the tests pass because the test database has one tenant, and the first person to notice is a customer looking at another customer's records.

In a shared-schema design, isolation is entirely an application-code responsibility unless it is paired with database-level row-level security. That means your isolation guarantee is only as strong as the least careful query in the codebase, written by any developer, on any day, including the ones that come after launch.

So the real design question is where isolation gets enforced. There are three workable models, and a fourth option that sits on top of the cheapest one.

What are the three isolation models, really?

Shared schema with a tenant_id column. Every tenant's rows live in the same tables, tagged by a tenant identifier. It is the cheapest to run and the easiest to scale to thousands of small tenants.

Schema per tenant. One database, but each tenant gets its own set of tables in its own schema. Isolation is stronger and you still operate one database, but migrations must run against every tenant's schema, which is manageable at dozens of tenants and painful past a few hundred. There is also a ceiling: scalability is limited by the number of tables the database can handle, with significant metadata overhead at very large table counts.

Database per tenant. Each tenant gets its own database. This is the model used by enterprise SaaS products with strict compliance requirements such as HIPAA, SOC 2 Type II and FedRAMP, where each tenant's data is physically separate. The price is relatively extensive hardware and maintenance requirements, high costs, and poor scaling to thousands of tenants.

Shared schema + tenant_idSchema per tenantDatabase per tenant
Isolation enforced byApp code, or RLS if you add itSearch path and grantsSeparate connection credentials
Hosting costLowestLow to moderateHighest, grows per tenant
MigrationsOnceOnce per tenantOnce per tenant, across databases
Connection poolingOne poolOne pool, per-request search_pathA pool (or pooler config) per database
Per-tenant restoreHard (row-level extraction)ModerateEasy
Noisy neighbor riskHighestHighLowest
Typical fitMany small or mid tenantsRarely the best fitContractual physical separation

Bytebase's guidance matches my own experience: start with shared database, shared schema whenever possible, move to database per tenant only when compliance, scale or customization demands it, and avoid separate schemas because they combine the drawbacks of both models.

The costs that surprise small operators with database-per-tenant are not only storage. Every database needs its own connection pool, backups, restore tests, monitoring, and a migration run that can fail halfway across your fleet. At 20 tenants that is a script. At 400 it is a part-time job.

How does row-level security stop a forgotten WHERE clause?

PostgreSQL RLS, introduced in version 9.5, attaches security policies to a table so every SELECT, INSERT, UPDATE and DELETE is filtered automatically; rows that fail the policy behave as if they don't exist. The forgotten AND tenant_id = $1 stops mattering, because the database adds the equivalent predicate itself.

The policy

The pattern practitioners recommend is a NOT NULL tenant_id with a foreign key to a tenants table on every tenant-owned table, RLS enabled, and a policy comparing tenant_id to a session-local setting set in the per-request handler before any tenant SQL runs.

CREATE TABLE tenants (
  id   uuid PRIMARY KEY,
  name text NOT NULL
);

CREATE TABLE invoices (
  id           uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id    uuid NOT NULL REFERENCES tenants(id),
  amount_cents bigint NOT NULL,
  status       text NOT NULL
);
CREATE INDEX ON invoices (tenant_id, status);

ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON invoices
  USING      (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid)
  WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid);

The NULLIF(..., '') detail matters. If the setting was never set, or was reset to an empty string, the comparison becomes NULL and the policy returns zero rows instead of throwing a cast error or matching something unintended. The policy fails closed.

Setting the tenant per request

BEGIN;
SELECT set_config('app.tenant_id', $1, true);  -- true = local to this transaction
-- tenant queries here
COMMIT;

Scope the setting to the transaction, not the session. With a transaction-mode connection pooler, a session-level setting can survive on a pooled connection and be picked up by the next request, which is the exact leak you are trying to prevent. Derive $1 from the authenticated session on the server, never from a request parameter.

The gotchas that defeat it

A CI check can be a single query that fails the build if it returns rows:

SELECT n.nspname, c.relname
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_attribute a ON a.attrelid = c.oid
 AND a.attname = 'tenant_id' AND NOT a.attisdropped
WHERE c.relkind IN ('r', 'p')
  AND n.nspname NOT IN ('pg_catalog', 'information_schema')
  AND (NOT c.relrowsecurity OR NOT c.relforcerowsecurity);

Keep the WHERE tenant_id = $1 in application queries anyway. It helps the planner use your composite indexes and makes intent obvious to reviewers. RLS is the backstop, not a license to stop writing the filter.

Which model fits your compliance and cost constraints?

Start with what the stakes are. IBM's 2025 Cost of a Data Breach Report put the average U.S. breach at a record $10.22 million. Healthcare topped the industry list at $7.42 million, and customer PII was the most frequently compromised data type, involved in 53 percent of breaches. A client portal holding names, addresses and account numbers is holding exactly that.

Louisiana adds a legal floor. La. R.S. 51:3074(A) requires entities doing business in Louisiana or holding computerized personal information to maintain "reasonable security procedures and practices appropriate to the nature of the information". The breach notification law, effective January 1, 2006, requires notifying Louisiana residents whose unencrypted personal information was, or is reasonably believed to have been, acquired by an unauthorized person. Notice is due within 60 days of discovery, and violations are treated as unfair trade practices, with penalties up to $5,000 per violation when committed with intent to defraud or against an elderly or disabled person. Late notice to the Attorney General can draw up to $5,000 per violation, with each day counting separately.

Note what that means for a tenant leak: your application decrypts data before returning it, so encryption at rest does nothing when the wrong client is served the right rows. And when you have to explain your "reasonable security procedures" to an auditor or to management, "every developer remembers the WHERE clause" is a weak answer. "The database refuses to return rows outside the caller's tenant, and CI blocks unprotected tables" is a strong one.

My decision rule:

  1. Default: shared schema, tenant_id everywhere, RLS with FORCE and WITH CHECK, CI check, dedicated non-owner app role.
  2. Move a tenant to its own database when a specific contract names HIPAA, SOC 2 Type II, FedRAMP or physical separation, or when one tenant's load or customization needs no longer fit the shared model. Hybrid is fine: most tenants shared, a few isolated, one codebase routing by tenant.
  3. Schema per tenant only if you have a small, fixed number of tenants that each need custom tables, and you accept per-schema migrations.

I rebuilt a back office as a serverless, white-label, multi-tenant platform in 30 days, and the isolation model was one of the first decisions made, because retrofitting it later means touching every query and every table.

Who will maintain this after launch?

Isolation fails over time, not on day one. The risk is the developer who joins in year two, adds a table and doesn't know the rules. Your RLS setup and CI check are how the rules survive staff turnover.

Northeast Louisiana has a real pipeline for that work. Lumen Technologies lists its corporate headquarters in Monroe, and Louisiana Tech holds courses at that Monroe headquarters as well as in Ruston. Tech's computer science program is ABET-accredited and offers a 12-credit-hour cybersecurity concentration. ULM's Computer Information Systems program teaches database design and information security, and its computer science program won a $129,532 Board of Regents grant, with $89,800 in AWS in-kind support, to fund cybersecurity and cloud certification training for 150 students.

Graduates from those programs will know SQL. What they need from you is a codebase where the secure path is the default: policies already written, the tenant set in one middleware, and a build that fails when someone forgets.

Where to start

  • Inventory every table that holds client data and confirm each one has a NOT NULL tenant_id with a foreign key.
  • Check which database role your application connects as. If it owns tables or is a superuser, fix that first.
  • Enable RLS with FORCE and write USING and WITH CHECK policies, starting with the tables holding PII or financial data.
  • Move tenant context into one transaction-scoped set_config call in request middleware.
  • Add the catalog query above to CI, and write one integration test that logs in as tenant A and asserts it sees zero rows from tenant B.
  • Read your client contracts for physical-separation clauses before choosing database-per-tenant for everyone.

If you want a second opinion on your tenant isolation model or help putting RLS in place, email hello@josephleporati.com.

Common questions

Does row-level security slow down Postgres queries?

RLS adds a predicate to every query on a protected table, so its cost depends on whether that predicate can use an index. With tenant_id as the leading column in your composite indexes and a simple equality policy against a session setting, the overhead is usually small. Policies that call complex functions or subqueries per row are where performance problems appear, so keep policies to a direct column comparison.

Can we start with a shared schema and move big clients to their own database later?

Yes, and designing for it early makes it cheap. If every tenant-owned table carries tenant_id and your application resolves a database connection per tenant in one place, moving a tenant means copying its rows to a new database and changing its routing entry. Hybrid setups, with most tenants shared and a few isolated for contractual reasons, are common and run from a single codebase.

Does RLS work with a connection pooler like PgBouncer?

It does if tenant context is scoped to the transaction. Use set_config with its third argument set to true, or SET LOCAL, inside an explicit transaction so the value disappears at commit. Session-level settings are dangerous with transaction-mode pooling because a pooled connection can carry one tenant's setting into another request, which recreates the leak RLS was meant to prevent.

Does encrypting the database protect us from a cross-tenant leak?

No. Encryption at rest protects disks and backups, but your application decrypts data before serving it, so a query that returns another client's rows returns them readable. Louisiana's notification law centers on unencrypted personal information acquired by an unauthorized person, and data exposed through your own app to the wrong tenant fits that description. Isolation has to be enforced at query time.

Sources

  1. Frontegg: Multi-tenant architecture guide frontegg.com
  2. USPTO patent filing: Allocation of tenants to database services image-ppubs.uspto.gov
  3. ER Flow: Designing a multi-tenant database schema erflow.io
  4. Bytebase: Multi-tenant database architecture patterns explained bytebase.com
  5. Mastering PostgreSQL Row-Level Security for multi-tenancy ricofritzsche.me
  6. QueryPlane: Postgres row-level security in practice queryplane.com
  7. OneUptime: Row-level security in PostgreSQL oneuptime.com
  8. DEV Community: Database per tenant vs shared schema dev.to
  9. CyberScoop: IBM Cost of a Data Breach 2025 cyberscoop.com
  10. AllCovered: Key insights from IBM's 2025 Cost of a Data Breach Report allcovered.com
  11. Bluefin: IBM's 2025 data breach report key findings bluefin.com
  12. Recording Law: Louisiana data breach notification recordinglaw.com
  13. LSU ITS: Louisiana Database Security Breach Notification Law lsu.edu
  14. TermsFeed: Louisiana data breach law termsfeed.com
  15. Shreveport-Bossier Advocate: Lumen Technologies returns to Fortune 500 shreveportbossieradvocate.com
  16. Wikipedia: Louisiana Tech University College of Engineering and Science en.wikipedia.org
  17. Bachelorsportal: Louisiana Tech Computer Science bachelorsportal.com
  18. Louisiana Board of Regents Academic Agenda 2017 regents.la.gov
  19. ULM Computer Information Systems program ulm.edu
  20. ULM Computer Science cybersecurity grant ulm.edu

multi-tenant postgresql row-level security data security saas