Multi-Tenant SaaS with Postgres Row-Level Security

Appluex·June 12, 2026·10 min read
Software DevelopmentWeb

If you're building B2B software, you're building a multi-tenant system whether you call it that or not. Many companies use your app, and each one's data must stay completely separate from everyone else's. Getting that isolation right isn't a nice-to-have. One leak across tenants is the kind of incident that ends contracts. Here's the pattern we trust most, and why.

The naïve approach is to put a tenant_id column on every table and add WHERE tenant_id = ? to every query. It works right up until the day a developer forgets that clause on one query. And now Company A can see Company B's invoices. The problem is that security depends on every single query being written perfectly, forever. That's a bet you'll eventually lose.

Let the database enforce it

Postgres has a feature built exactly for this: Row-Level Security (RLS). Instead of trusting application code to filter rows, you tell the database itself that a connection can only ever see rows belonging to its current tenant. The filter is applied automatically to every query. Selects, updates, deletes. And there's no way to write a query that escapes it.

-- Turn on RLS and define the rule once, per table
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = current_setting('app.tenant_id')::uuid);

-- Then, per request, your app sets who's asking:
SET app.tenant_id = '...the-logged-in-tenant...';

After that, a plain SELECT * FROM invoices only ever returns the current tenant's rows. A forgotten WHERE clause is no longer a security hole. It's just a query. The rule lives in one place, enforced by the engine, not scattered across thousands of lines of code.

The goal is to make a cross-tenant leak impossible by construction, not merely unlikely if everyone codes carefully.

The one thing you must get right

RLS is only as good as the value you set for the current tenant. That SET app.tenant_idmust come from the verified session on the server. Never from anything the client can send. Set it at the start of each request, right after you authenticate the user, inside the same connection and transaction that handles the request. With a connection pool, reset it between checkouts so one request can't inherit another's tenant.

When to go further

For most SaaS products, shared tables with RLS is the sweet spot: simple to operate, cheap to scale, and strongly isolated. Some businesses need more. A schema or even a separate database per tenant. Usually because a large enterprise customer demands physical separation, or compliance requires it. But those add real operational cost (migrations across hundreds of schemas get painful), so reach for them only when a customer or a regulation actually requires it, not by default.

The takeaway

Tenant isolation should be enforced by the database, not by remembering to filter every query. Postgres RLS gives you that with a one-time policy per table and a per-request tenant setting driven by the server session. Build it in from day one. Retrofitting isolation onto a live multi-tenant app is far harder than starting with it. If you're architecting a B2B platform and want it done right, let's talk, or see what we've built.

Thinking about building this?

Appluex designs and ships production mobile & web apps. Including AI features. Let's talk.

Book a consultation →← All insights