How I Built Multi-Tenant Data Isolation for My SaaS CRM with PostgreSQL RLS
When I started building uniThread CRM, I wanted it to be more than just a basic CRUD project. I wanted to actually understand what it takes to build a SaaS application where multiple organizations share the same system without ever seeing each other's data.
That led me to one of the harder questions I had to answer while building it:
How do I make sure users can only ever access data belonging to their own organization?
My answer ended up being a combination of PostgreSQL Row-Level Security (RLS) and authorization logic on the backend. Here's how I got there, and why I didn't just trust one layer to handle it.
The Obvious Requirement
The rule is simple to state: Organization A should never be able to access Organization B's data. Simple to say, less simple to guarantee.
The easy way to handle this is to just add an organization_id filter to every database query in the backend. And that works... until someone forgets to add it. One missed WHERE clause on one endpoint, and suddenly you've got a data leak. I didn't want tenant isolation to depend entirely on every developer (including future me) remembering to add that filter every single time.
That's what got me looking into PostgreSQL RLS.
Designing Around Organizations
For any organization-specific CRM data, I use an organization_id column to tie each record to the organization that owns it. Nothing fancy, just:
CREATE TABLE leads (
id UUID PRIMARY KEY,
organization_id UUID NOT NULL,
name TEXT NOT NULL
);
That gives every lead a clear, explicit relationship to an organization. It sounds almost too simple to matter, but it's the foundation everything else builds on top of.
Bringing in Row-Level Security
PostgreSQL RLS lets the database itself decide which rows a query is allowed to see, instead of leaving that entirely up to the application code.
First, you turn it on for the table:
ALTER TABLE leads ENABLE ROW LEVEL SECURITY;
Then you write a policy that only allows access to rows belonging to the current user's organization:
CREATE POLICY "Users can access their organization leads"
ON leads
FOR SELECT
USING (
organization_id = get_user_organization_id()
);
Once that's in place, tenant isolation stops being something only my application layer is responsible for. The database is now enforcing it too. Even if I mess something up in the app code, there's still a wall between organizations at the data layer. That extra layer of protection is really what sold me on this approach.
Why I Didn't Just Rely on RLS
Even with RLS in place, I still wanted the backend to own authorization and business logic. RLS is great at stopping cross-tenant access, but it's not really designed to handle things like "can an Agent delete a lead" or "is this user even allowed to hit this endpoint."
So the backend follows a fairly standard layered structure:
Request
↓
Controller
↓
Service
↓
Repository
↓
PostgreSQL
The backend handles authentication, role-based access control, organization context, business rules, and validation. Then PostgreSQL adds its own layer on top through RLS.
The point isn't to make one layer do everything. It's to let each layer do what it's actually good at.
Roles
Right now, uniThread has three organization-level roles:
- Owner
- Manager
- Agent
A user's role determines what they're allowed to do inside their organization.
Working through this part is actually what made the difference between authentication and authorization click for me in a way it never had before.
Authentication asks: who are you?
Authorization asks: what are you allowed to do?
And tenant isolation asks a third, separate question: which organization's data are you even allowed to touch?
They sound like variations of the same idea when you read about them, but once you're actually implementing all three at once, the differences become a lot more obvious.
What I Learned
Before building uniThread, my mental model of auth was basically:
Login → JWT → Authenticated User
That's it. That was the whole picture in my head.
Building something multi-tenant broke that mental model pretty quickly. Getting a user authenticated is really just the starting point. After that you still have to figure out what they can access, what actions they're allowed to take, and how the database itself should be protecting that data regardless of what the app layer does.
It also made me a lot more deliberate about where security checks actually belong, and made me think harder about what happens the day a developer forgets to add one.
The Result
Between organization-scoped data, backend authorization, and PostgreSQL RLS, uniThread now has a decent foundation for keeping tenant data properly separated.
More than the technical result, though, this was one of the first parts of the project that pushed me past "just build the CRUD feature" and into actually thinking about architecture, security, data ownership, and what happens as the system grows.
What I'd Do Differently
If I were starting uniThread over, I'd think about tenant isolation a lot earlier than I did. It's way easier to bake organization ownership into your schema from day one than to retrofit it after you've already built a pile of features on top of a shaky foundation.
I'd also spend more time up front defining authorization rules, instead of discovering the edge cases one at a time while implementing individual features and going "oh... wait."
Final Thoughts
uniThread started out as just "I want to build a CRM." Somewhere along the way it turned into a much better lesson in how SaaS applications are actually put together under the hood.
Multi-tenancy was one of those topics that looked simple from the outside and turned out to be way more interesting than expected once I actually had to implement it myself.
I'm still learning as I go, but honestly, that's a big part of why I keep building projects like this. The interesting part was never just making the app work. It's figuring out how to make it work correctly as things get more complicated.
Top comments (0)