Stage 2 · First Version · Chapter 10

Multi-Tenant SaaS Database Design: Keep Each Company's Data Separate

Design one shared database so each company sees only its own data: company columns, Supabase Row Level Security, function guards and a test that proves it.

Dr. Yahia Elkhouli · October 28, 2026 · 7 min read

On September 25, 2026, I asked my AI assistant whether we should give every KashBoss customer its own web address, a separate subdomain with the company's name in it. Its answer surprised me. A separate address looks professional, it said, but it is not security. What keeps companies apart is the database. So I asked for an audit, with one condition: from the database itself, not from your memory.

The tables passed. Every table had rules, and a logged-in user could read only their own company's rows. But the database also contains functions, small programs the app calls to do a job such as saving an invoice. A long list of them did not check who was calling. The tables were locked, but these functions ran with the database owner's powers and did whatever they were asked, for whichever company they were asked about.

We fixed them. This article is the constructive version: how to design multi-company data from the first table so the hole never opens.

The short answer

In a multi-tenant SaaS, every customer table carries a company column that cannot be empty, the database works out the caller's company from the login, row-level rules filter every read and write, and every powerful function checks the caller itself on its first line. Then you prove it with a test that logs in as one company and tries to reach another. Row rules alone are not enough, because functions that run with elevated rights bypass them.

What multi-tenant means

A SaaS product serves many customer companies from one system. Each company is a tenant. Think of an apartment building: everyone shares the structure, the pipes and the elevator, which keeps it affordable, but every apartment has its own lock and nobody's key opens a neighbor's door. You, the platform owner, hold the master key, and it must be guarded more carefully than any other.

There are three common designs: a separate database per company (strongest, but every update runs hundreds of times), a separate schema per company (strong but complex), or shared tables with a company column (cheapest, one update for everyone). Most SaaS products, including ours, use shared tables. The price is discipline: one missing rule in one place and companies can see each other.

Separation is built from four layers, each covering a different door:

  1. Company column: every customer row records its company.
  2. Who am I: the database works out the caller's company from their login, never from what the app says.
  3. Row rules: the database filters every table read and write by the caller's company. In Supabase these are called Row Level Security.
  4. Function guards: every function that can bypass the row rules checks the caller itself.

The fourth layer is the one beginners miss, and the one we missed.

How we did it in KashBoss

A recipe for every new table

Every table holding customer data has a business_id column that cannot be empty, plus four row rules (read, add, change, delete), each saying the same thing: this row's company must equal the caller's company. If a screen sends a company ID, the database treats it as a request, not a fact.

THE PROMPT
Create a new table called [TABLE NAME] for [WHAT IT STORES] in our Supabase database.
It must have a business_id column that can never be empty.
Turn on Row Level Security with four rules (read, add, change, delete),
each allowing a user to touch only rows of their own company, using our existing "who am I" function.
Explain each rule in one plain sentence before showing the SQL.
Then tell me which other doors could reach this table (functions, storage, scheduled jobs) and how each is protected.

The last line matters. A table with perfect rules is still exposed if a function next to it ignores them.

Sort every table: tenant, platform or public

Before writing rules, sort your tables. Tenant tables hold a company's own data. Platform tables hold your data about the company, such as its subscription and plan, and only you may change them. Public tables hold things anyone may read, like the plans on a pricing page.

THE MISTAKE WE MADE

Our subscriptions table was treated like tenant data because each row belongs to a company. Its rules let any user inside a company update that company's own subscription row. In September 2026 we proved, in a test that undid itself afterward, that the owner of a trial account could switch his own subscription to active for ten years. A month earlier we had found that the table listing all companies had an extra rule, probably added for the signup moment: anyone not yet linked to a company could read all companies. Employee accounts were created without that link, so employees could read the names, emails and phone numbers of every company on the platform.

Subscriptions are now platform data. Only the platform admin changes them, and every legitimate change goes through a server function with its own checks. Neither mistake came from a missing rule. Both came from a rule too generous for what the table really was.

Guard every function that bypasses the row rules

Some functions run with the database owner's full rights so they can do big multi-table jobs. Saving an invoice touches the invoice, its lines, stock, the cash box and the accounting journal. Inside those functions the row rules do not apply. Our September fix was one small guard on the first line of every function that receives a company ID: allow only if the caller belongs to that company or is the platform admin, otherwise refuse. We applied it to over a hundred functions in one day. Others were internal helpers the app never needs, so ordinary users lost the right to call them. A few stayed open on purpose, each with a written reason, such as login functions that must work before anyone is logged in.

Two details matter. Background jobs run inside the database with no logged-in user, so the guard must tell a genuine internal job from an outside visitor who is not logged in. "No user" must never mean "allowed." And a company ID is not the only ID a function receives. A recipe arrives with product IDs inside it, and each must also be proven to belong to the caller's company.

THE PROMPT
List every database function in this project that runs with elevated rights (SECURITY DEFINER).
For each one, tell me:
1. Which company IDs or record IDs it receives from the app.
2. Where it checks that the caller belongs to that company, quoting the exact line.
3. Whether a background job or a logged-out visitor can call it.
Mark every function with no check. Propose one shared guard function to add at the top of each,
and list any function that must stay open (such as login) with the reason.
Do not apply anything until I approve the list.

Prove it with a test that undoes itself

The test is simple to describe. Log in as a user from Company A, try to read and change Company B's rows in every table, and call every function with Company B's ID. Every attempt must return nothing or be refused. Run it inside one transaction that is canceled at the end, so no data changes even on a real database.

Two traps hit us. First, pretending to be a user is not enough. If the test sets the login identity but does not switch to the ordinary user's database role, it keeps running as the administrator, who ignores every row rule. Our first test seemed to show a massive leak. With the role switched properly, the answer was zero. Second, a refused change looks like success. When a row rule blocks an update or delete, the database does not return an error. It replies, in effect, "done, zero rows changed." Always check that a change really touched a row.

THE PROMPT
Write an isolation test for [TABLE OR FEATURE] that undoes all its changes at the end.
1. Log in as a real user of Company A, and switch to the normal logged-in database role, not the admin role.
2. Try to read, add, change and delete Company B's rows in every related table.
3. Call every related function with Company B's ID and with Company B's record IDs.
4. For every change, check how many rows were actually affected, not only whether an error appeared.
Show me a table of each attempt and its result. Every attempt must be refused or affect zero rows.

Key takeaways

Frequently asked questions

What is multi-tenant architecture in SaaS?

It means one system serves many customer companies, called tenants, while keeping each company's data private. Most products use shared tables with a company column on every row, and rules in the database that filter by the caller's company.

Is a separate subdomain per customer enough to keep data separate?

No. A separate address looks professional but is not security. What separates companies is the database: company columns, row rules and checks inside every powerful function.

How do I test that one tenant cannot see another's data?

Log in as a user from one company, in the normal user database role, and try to read, change and call functions against another company's rows and IDs. Run it inside a transaction that is canceled at the end, and check how many rows each attempt affected.