Multi-Tenant From Day One: Schemas, Row-Level Security, and the Mistakes I See Weekly
A practical guide to building multi-tenant SaaS on Postgres — why shared-schema with row-level security wins at seed stage, the implementation patterns that work, and the five mistakes that cause data leaks.
I review seed-stage codebases almost every week. The single most common architectural regret I encounter is not this: "We picked the wrong framework." It is this: "We bolted multi-tenancy on eighteen months after launch and it nearly killed us."
Retrofitting tenant isolation into a single-tenant codebase is one of the most painful engineering projects a startup can take on. Every query needs auditing. Every migration becomes a risk. Every corner of the app that ever assumed "there is only one customer" turns into a potential data leak. I have watched teams spend three to four months on this work when they could have spent three to four days doing it correctly at the start.
This article is the three-to-four-day version. Specifically: which multi-tenant model to pick, how to implement Postgres row-level security properly, the middleware pattern that makes it invisible to your application code, and the five mistakes I see teams make repeatedly.
Why it matters: the cost of retrofitting
The cost is not just engineering hours. It is risk. When you retrofit multi-tenancy, you are changing the security boundary of every query in your application simultaneously. Miss one WHERE clause and you have a data breach. Miss one migration and you have orphaned rows. Miss one edge case in your background jobs and one customer's data shows up in another customer's dashboard.
I have seen this happen at companies with good engineers. The problem is not competence — it is surface area. A mature SaaS has hundreds of queries, dozens of background workers, and multiple data access patterns. Touching all of them at once, under time pressure, while the product is live, is where things go wrong.
Starting multi-tenant from day one means you never have to do this. The cost is a few hours of setup and a discipline of always including tenant context. That is it.
The three models (and why you should probably pick the boring one)
There are three standard approaches to multi-tenant data isolation, and the decision is simpler than most teams make it.
Shared database, shared schema is the model where all tenants share the same tables and a tenant_id column on every tenant-scoped table distinguishes who owns what. This is the cheapest to operate, the simplest to migrate, and the easiest to scale. It is also the model where a missing WHERE clause is a data breach, which is why you pair it with row-level security.
Shared database, separate schemas gives each tenant their own Postgres schema within a single database. Logical isolation is stronger, auditors like it, and you can grant per-schema permissions. The tradeoff: every migration runs once per tenant. At 500 tenants, that is 500 migration executions. Tools like Atlas handle this, but the operational complexity scales linearly with your tenant count and adds roughly 20-30% overhead compared to shared schema.
Database per tenant provides full physical isolation. Each tenant gets their own Postgres instance. It is the right answer when regulation demands it — certain healthcare and financial services contracts require it. For everyone else, infrastructure costs jump five to eight times compared to shared schema for the same tenant count, and you are now managing a fleet of databases instead of one.
My default recommendation at seed stage: shared database, shared schema, with Postgres row-level security enforcing isolation at the database layer. Start here. Graduate to separate schemas or separate databases only when a specific compliance or contractual requirement forces you to.
Implementing row-level security the right way
Row-level security lets you define policies at the database level that restrict which rows a given session can see or modify. The key advantage over application-level filtering: even if your application code forgets to filter by tenant_id, the database enforces it anyway. It is a safety net, not just a feature.
Here is the setup pattern I use on every engagement.
First, create a dedicated application role that is not a superuser and does not own the tables. Superuser roles and table owners bypass RLS entirely — this is the single most common misconfiguration I see.
Second, enable RLS on every tenant-scoped table and force it even for the table owner. The distinction matters: ENABLE ROW LEVEL SECURITY alone still lets the table owner bypass policies. You need FORCE ROW LEVEL SECURITY as well if your application connects as the table owner (which it should not, but defense in depth).
Third, create policies that read tenant context from a session variable. I use app.current_tenant set via set_config. The policy looks roughly like this: a SELECT policy that checks tenant_id = current_setting('app.current_tenant')::uuid, and an INSERT policy with a matching WITH CHECK clause. You need both — a policy that only filters reads but does not validate writes is a hole. I have seen teams create SELECT policies and forget INSERT/UPDATE/DELETE, allowing any tenant to write rows with any tenant_id.
Fourth — and this is where connection pooling gets dangerous — always set tenant context at the transaction level, not the session level. If you use PgBouncer in transaction mode (and you should, for connection efficiency), session-level variables persist across different clients sharing the same connection. That means one tenant's context can leak to the next request on that connection. Always use transaction-scoped set_config with the third parameter set to true, meaning the setting is local to the current transaction.
The middleware pattern
The goal is to make tenant isolation invisible to your application code. No developer should ever need to remember to add a WHERE clause. The database handles enforcement, and your middleware handles context.
In a Next.js application with Prisma or Drizzle, the pattern works like this: your auth middleware resolves the current user and their tenant from the session or JWT. Before any database query executes, middleware sets the app.current_tenant session variable on the connection. Every query then runs through RLS policies automatically.
With Prisma, you can use client extensions to inject a raw SET LOCAL app.current_tenant = $tenantId at the start of every transaction. With Drizzle, you can wrap your database client in a transaction helper that sets context before executing the caller's queries. Either approach works. The key is that it happens in exactly one place in your codebase, not scattered across every query.
For background jobs and cron tasks, the same discipline applies: set tenant context before executing any tenant-scoped work. I have seen teams get RLS perfect in their API layer and then bypass it entirely in their job runners because the job fetches data with a privileged connection. Every data access path needs the same treatment.
The five mistakes I see every week
Mistake one: running as a superuser or table owner. Superuser roles bypass all RLS policies silently. Your application should connect as a restricted role with only the privileges it needs. This sounds obvious, but I see it in roughly half the codebases I review.
Mistake two: policies on SELECT but not on INSERT, UPDATE, or DELETE. A read-only policy means any authenticated user can insert rows with any tenant_id. You need policies with matching WITH CHECK clauses for every operation type.
Mistake three: session-level tenant context with connection pooling. As described above, PgBouncer in transaction mode reuses connections. Session-level set_config means the next request on that connection inherits the previous tenant's context. Use transaction-scoped set_config exclusively.
Mistake four: forgetting to set context in background jobs. Your API routes set tenant context through middleware. Your background workers use a different code path. If that code path does not set context, those workers have either no tenant context (seeing nothing) or a stale context from a previous job (seeing the wrong tenant's data).
Mistake five: not testing tenant isolation explicitly. Most test suites test that a feature works. Almost none test that tenant A cannot see tenant B's data. Add cross-tenant visibility tests to your integration suite. Create two tenants, create data for each, and assert that queries scoped to tenant A never return tenant B's rows. This is a fifteen-minute investment that catches real bugs.
Where this advice breaks down
If you are building for industries with strict data residency requirements — certain healthcare, government, or financial services contracts — shared schema with RLS may not satisfy your customers' compliance teams. Some enterprise buyers contractually require physical database isolation, and no amount of RLS sophistication will change that. In those cases, schema-per-tenant or database-per-tenant is not a preference; it is a requirement.
Similarly, if your tenants have wildly different data volumes — one tenant with 100 rows and another with 100 million — shared schema can create noisy-neighbor performance problems. At that scale, you may need tenant-aware connection routing or dedicated compute for your largest tenants.
But for the vast majority of seed-stage SaaS products, shared schema with RLS is the right starting point. You can always add isolation later. You cannot easily add multi-tenancy later.
Action checklist
Add a tenant_id column (UUID, NOT NULL, indexed) to every table that holds tenant-scoped data. Do this on your first migration, before you write any application code.
Create a dedicated Postgres role for your application with no superuser privileges and no table ownership. Grant only SELECT, INSERT, UPDATE, DELETE on the tables it needs.
Enable RLS on every tenant-scoped table with both ENABLE ROW LEVEL SECURITY and FORCE ROW LEVEL SECURITY.
Write RLS policies for all four operations (SELECT, INSERT, UPDATE, DELETE) using current_setting('app.current_tenant') as the tenant filter.
Build a middleware layer — one place in your codebase — that sets app.current_tenant via transaction-scoped set_config before every database operation.
Apply the same tenant context pattern to background jobs, cron tasks, and any other non-HTTP data access paths.
Add cross-tenant isolation tests to your integration suite: two tenants, shared data, explicit assertions that isolation holds.
Audit your connection pooling configuration to confirm you are using transaction-scoped settings, not session-scoped.
Multi-tenancy is not a feature you add later. It is a foundation you lay on day one. The setup takes hours. The retrofit takes months. Every week I review a codebase where a team chose "later," and every week I wish they had not.
If you are weighing this decision at your startup, I do exactly this work as a fractional CTO. Book a 30-minute call.
Craig Hoffmeyer is a fractional CTO who helps seed-stage SaaS startups build scalable, secure technical foundations. Read more at hoffdigital.com.
Related articles:
Building a multi-tenant SaaS and want a second opinion on your isolation model? Book a call.
Get in touch →