When you sell one product to many customers, every business table holds rows belonging to different tenants, side by side. The entire value of the product rests on a single guarantee: tenant A can never see tenant B's data. Break it once and you don't have a bug — you have a breach, and in SaaS that's often fatal.
The tempting way to enforce isolation is in application code: every query gets a WHERE tenant_id = ? bolted on. It works right up until the day someone writes a query that forgets the clause — a new report, an admin tool, a junior's first PR. There's no safety net. The isolation is only as strong as the most careless query anyone ever writes.
I push that guarantee down into the database with Postgres Row-Level Security (RLS). The database itself refuses to return rows that don't belong to the current tenant, regardless of what the query says. Here's how that looks in production.
The model: tenant_id everywhere, enforced by the database
On Mudarris, a multi-tenant SaaS for tutoring centers, every business row carries a tenant_id, and RLS policies derive the active tenant from the request's JWT:
-- the tenant is read from the verified JWT, not from a query parameter
create or replace function get_current_tenant_id()
returns uuid language sql stable as $$
select (auth.jwt() ->> 'tenant_id')::uuid
$$;
alter table students enable row level security;
create policy tenant_isolation on students
using (tenant_id = get_current_tenant_id());
With that policy in place, SELECT * FROM students returns only this tenant's students — even if a query forgets to filter, even from a brand-new feature, even from a mistake. The filter is not optional and not the application's responsibility. The database enforces it on every read and write.
This inverts the usual risk model. Instead of "isolation holds unless someone forgets," it becomes "isolation holds unless someone explicitly disables RLS" — which is a deliberate, reviewable, alarming action rather than a silent omission.
The trap: don't ship the master key
RLS only protects you if the client can't bypass it. The classic mistake is shipping Postgres's service_role key (which ignores RLS) inside a mobile or desktop app "to make sync work." Now your isolation is one decompiled APK away from gone.
Mudarris's teacher app runs fully offline and syncs to the cloud — exactly the scenario that tempts people to embed a powerful key. Instead, the device never holds the service-role key. It authenticates cloud sync with a scoped, revocable sync token, and the moment that token is rejected (401/403) the app transparently re-authenticates. The privileged, RLS-bypassing operations happen only inside server-side Edge Functions that the device cannot impersonate.
Least privilege on the client is non-negotiable. If a key on the device can read the whole database, RLS on the server is theater.
Gate the limits twice
Multi-tenancy isn't only about isolation — it's also about plan enforcement. A tenant on the free plan shouldn't exceed 30 students; a Basic plan caps cloud sync and WhatsApp sends. If those limits are checked only in the app, they're checked nowhere — a tampered client just lies.
On Mudarris, plan limits are enforced twice: once client-side for instant UX feedback, and again server-side inside the cloud-sync function, which is the real gate. The client-side check is a courtesy; the server-side check is the law. You can delete the app's check entirely and the limits still hold.
The same pattern, different shapes
RLS-style isolation showed up in every multi-tenant product I built, adapted to its stack:
| Platform | Tenancy model | Isolation mechanism |
|---|---|---|
| Mudarris | One tenant = one tutoring center | Postgres RLS keyed off the JWT's tenant_id; scoped sync tokens on-device |
| Qooty | One tenant = one retail partner | A second backend process (partner.js) where marketAuth middleware scopes every query to the partner's marketId — partners never see siblings' offers or analytics |
| Stravo | Customers vs. staff | RLS policies plus an is_staff() predicate; the publishable key is safe in the browser because access is enforced entirely by Postgres policies |
Qooty is worth a note: the partner portal reuses the entire admin codebase, booted as a separate process with one piece of middleware enforcing market scoping on top. One body of code serves both the platform operator and self-service partners, with isolation as a thin, auditable layer — not a forked, drifting second app.
Stravo makes the strongest statement of intent: because RLS does the enforcing, the Postgres publishable key is deliberately exposed in the browser. There's nothing to leak. Every access path — customer or staff — goes through policies the client cannot talk its way around.
What you actually get
When isolation lives in the database, three good things follow:
- New features are safe by default. A developer building a new report can't accidentally leak across tenants, because the database won't let them.
- The blast radius of a query bug shrinks from "data breach" to "this tenant sees less than they should" — annoying, not catastrophic.
- Audits get short. "How do you guarantee tenant isolation?" has a one-line answer: RLS, enforced in Postgres, with no service-role key on any client.
Selling one product to many customers safely is the whole game in SaaS. I'd rather make that guarantee structural — something the database enforces on every row — than a promise that holds only as long as nobody makes a mistake.