Skip to content
All posts
saaspostgresqlsecuritymulti-tenancy

Selling one product to many tenants: Postgres Row-Level Security in production

May 20, 2026 4 min read· Mahmoud Makhlouf

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:

PlatformTenancy modelIsolation mechanism
MudarrisOne tenant = one tutoring centerPostgres RLS keyed off the JWT's tenant_id; scoped sync tokens on-device
QootyOne tenant = one retail partnerA second backend process (partner.js) where marketAuth middleware scopes every query to the partner's marketId — partners never see siblings' offers or analytics
StravoCustomers vs. staffRLS 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.

// Let's build

Have a product in mind? Get a clear plan and a free 30-minute consultation.

Tell me what you're building. I'll come back with an approach, a rough timeline, and a ballpark — usually within 24 hours.

WhatsApp
WhatsApp