Architecting Multi-Tenant Isolation in Next.js

A
Akram
8th Sep 20256 min read

Picture an apartment building. Every unit has its own door, its own lock, its own mailbox. Nobody expects to be able to wander into unit 4B just because they live in unit 4A down the hall. The building has one shared structure, one shared roof, one shared water line, but the units themselves are sealed off from each other.

That's basically what multi-tenant software is trying to be. One application, one database, one codebase, serving many different customers, each of whom should only ever be able to see their own stuff. The customers are the tenants. The apartment building is your infrastructure. And just like a building, it's entirely possible to build one that looks secure from the outside while quietly having no real locks on the doors.

This is a guide about how that happens, and what building it properly actually looks like. It's the same core mistake behind a different bug we wrote about, a rule that only lives where the user can see it isn't a rule at all.

The mistake almost everyone makes first

Here's the most common way teams build multi-tenancy, especially early on. Every table in the database gets a tenant_id column. Every query in the application code adds a WHERE tenant_id = currentTenant. Simple, fast to build, and it works, right up until it doesn't.

The problem is where that filter lives. It lives in application code, meaning it lives in the hands of every single developer who ever touches that codebase, forever. One new engineer writes a report query for an admin dashboard, forgets the tenant filter because they're focused on getting the numbers right, and now that endpoint quietly returns every tenant's data mixed together. Nobody notices in testing because the test account probably only has one tenant's worth of data anyway.

This isn't a hypothetical. It's one of the most common causes of real data leaks in SaaS products, and it's rarely caused by a hacker doing anything clever. It's caused by a missing line in a query that nobody ran through the same discipline as the rest of the app.

Think back to the apartment building. This approach is like telling every maintenance worker "please only enter the correct unit" and trusting that instruction alone, with no actual lock on any door. Most of the time it works, because most people follow instructions most of the time. But "most of the time" is not what you want from a security boundary. A boundary that only holds because everyone remembered to check is not really a boundary. It's a habit.

Push the enforcement down, not just the intention

The fix is to stop relying on every developer remembering to write the right WHERE clause, and instead make it structurally difficult, ideally impossible, to query across tenants by accident.

There are a few ways to actually do this, and they layer well together.

Row Level Security at the database. Most modern databases, Postgres included, support policies that get enforced by the database itself, not by your application. You define a rule like "a query can only see rows where tenant_id matches the current session's tenant," and the database applies it to every single query, automatically, regardless of what the application code asked for. If a developer forgets the filter, the database silently narrows the result anyway. The lock moves from a sticky note on the door to an actual lock built into the frame.

A proxy layer between your app and your database. Instead of every part of your app connecting to the database directly, requests go through a thin layer that stamps the tenant context onto the connection before any query runs. This is closer to a building having one front desk that verifies your key card before letting you anywhere near the elevators, rather than trusting each individual door to check on its own. Even if application code has a bug, the proxy layer is a second, independent checkpoint that doesn't share the same blind spots.

Separate schemas or databases for higher-risk tenants. For smaller or lower-risk tenants, a shared database with strong row level security is usually fine and much cheaper to run. But for enterprise customers, regulated industries, or anyone with strict compliance requirements, physically separating their data into its own schema or even its own database instance means a bug in the shared code path simply cannot leak between them. There's no shared table to leak across in the first place. It's the difference between apartments in the same building and separate houses on separate streets. More overhead, but a fundamentally different blast radius if something goes wrong.

None of these approaches require you to trust developer discipline as the actual security mechanism. Discipline is still good to have, code review still matters, but it should be the second layer of defense, not the only one.

A concrete example

Say you're running a project management SaaS, and two customers, a marketing agency and a law firm, are both using it. A support engineer needs to debug a bug report from the agency, so they log into an internal admin tool and run a quick query to pull up recent project activity.

Without database-level isolation, a typo in that internal tool's query, maybe someone forgot to scope it by tenant because "it's just an internal debugging tool, it's fine," could return activity from the law firm's account too. Nobody meant for that to happen. Nobody was trying to do anything wrong. But now confidential legal case data has been displayed on a support engineer's screen, and that's the kind of incident that ends up in a breach disclosure email, not a stern Slack message.

With Row Level Security enforced at the database and a proxy layer stamping tenant context onto every connection, that same typo simply returns nothing, or throws an error, because the database itself has no concept of "just this once, ignore the tenant boundary." The safety doesn't depend on the support engineer, or the internal tool's code quality, or anyone remembering anything. It's baked into the floor the whole building stands on.

What this costs you

Being honest here matters, because "just add Row Level Security" makes it sound free, and it isn't quite.

Row Level Security policies need to be tested as rigorously as any other security-critical code, because a badly written policy can create the same problems it's meant to prevent, just one level deeper and harder to spot. A proxy layer adds a small amount of latency and one more piece of infrastructure to operate and monitor. Separate databases per tenant multiply your operational overhead, backups, migrations, monitoring, all of it, by however many tenants you isolate that way.

The right amount of isolation depends on what you're protecting and who you're protecting it from. A tool for internal team collaboration with low sensitivity data can probably live happily with shared tables and solid Row Level Security. A platform handling financial records, health data, or legal documents probably justifies the extra cost of harder separation for at least your higher-risk tenants.

The takeaway

Multi-tenancy isolation that lives only in application code is a rule, not a lock. It depends on every developer, every query, every code review, getting it right, forever, without exception. That's a lot of weight to put on human memory.

Real isolation means the database itself refuses to answer a cross-tenant question, no matter who asks, no matter how the request got malformed, no matter what mistake slipped through code review. Build the lock into the door, not into a note taped next to it.

Written by Akram

Technical Specialist

All Blogs