Data Leakage & Row-Level Security (RLS): What It Solves — and What It Doesn't
When One Missing Filter Becomes a Real Problem
When One Missing Filter Becomes a Real Problem
Let's be honest — this kind of issue happens more often than teams like to admit.
You write a query, everything looks fine, it passes code review… but one small detail is missing:
WHERE user_id = ...That's it. That's the bug.
In a multi-tenant system, that single omission can expose data between users. Not always millions of records, not always system-wide — but enough to be serious. Sometimes it's just one endpoint. Sometimes only certain users notice. But the risk is real.
And here's the important part: this usually isn't caused by hackers. It's caused by normal development mistakes.
The deeper problem isn't the missing filter. It's this:
The system relies entirely on the application layer to enforce data access.
That's where Row-Level Security (RLS) comes in.
So, What Is Row-Level Security — Really?
Think of RLS as moving part of your authorization logic into the database.
Normally, your app does this:
- Check if user is logged in
- Run a query with filters
But the database itself doesn't "know" which user owns which data. It just executes whatever query it receives.
With RLS, the database starts enforcing rules like:
"This row belongs to user X. Only user X can see it."
So even if your query forgets the filter… the database will still refuse to return unauthorized rows.
That's the key shift:
- Without RLS → the app is responsible
- With RLS → the database enforces it too
And no — this doesn't replace your backend logic. It backs it up.
Why Most Teams Don't Use RLS
If it's this useful, why isn't everyone using it?
A few very practical reasons:
- It's unfamiliar Most developers are trained to handle access control in the backend, not the database
- It's not plug-and-play
You need a way to pass user context (like
user_id) into the database session - ORMs give a false sense of safety Tools like Prisma or Sequelize make queries cleaner — but they don't prevent logical mistakes
- It feels like overkill For small or simple apps, teams often skip it
All of that is understandable. But in multi-tenant systems, skipping RLS is a calculated risk.
Step 1 — Turn It On (and Use the Right Role)
Let's start simple.
You have a table like this:
CREATE TABLE documents (
id UUID PRIMARY KEY,
user_id UUID NOT NULL,
title TEXT,
content TEXT
);Now enable RLS:
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;At this point, something important happens:
If you don't define any policies, no data is accessible at all.
That's intentional. PostgreSQL defaults to deny all.
Also — don't connect your app using a superuser. Superusers can bypass RLS entirely, which defeats the purpose.
Step 2 — Tell the Database Who the User Is
This is where things usually get confusing — but it doesn't have to be.
You pass the current user's ID into the database session.
Then you write a policy like this:
CREATE POLICY user_isolation_policy
ON documents
FOR SELECT
TO app_user
USING (
user_id = current_setting('app.current_user_id')::UUID
);What this means in plain terms:
"Only return rows where
user_idmatches the current session's user."
Now from your application (example in Node.js):
await client.query(
'SET LOCAL app.current_user_id = $1',
[userId]
);After that, this query:
SELECT * FROM documents;…will automatically return only that user's data.
No WHERE clause needed. The database enforces it.
Step 3 — Actually Test It (Don't Skip This)
This is where many teams get into trouble.
They define policies… and assume everything is fine.
You need to simulate real conditions.
For example:
SET ROLE app_user;
SET app.current_user_id = 'some-uuid';
SELECT * FROM documents;Now check:
- Do you only see your own data?
- What happens if the user ID is missing?
Try this:
RESET app.current_user_id;
SELECT * FROM documents;A safe setup should either:
- return no rows, or
- fail safely
You can enforce that with:
USING (
user_id = NULLIF(current_setting('app.current_user_id', true), '')::UUID
)If the context isn't set → access is denied.
Real-World Case: Organization-Level Access
In most SaaS apps, access isn't just per user — it's per organization.
So instead of:
"user can see their own data"
It becomes:
"users can see data within their organization"
Example:
CREATE POLICY org_policy
ON projects
FOR SELECT
TO app_user
USING (
org_id = current_setting('app.current_org_id')::UUID
);And maybe:
CREATE POLICY owner_policy
ON projects
FOR UPDATE
TO app_user
USING (
owner_id = current_setting('app.current_user_id')::UUID
);Now you have layered control:
- org-level visibility
- user-level ownership
What RLS Does NOT Protect You From
This part matters.
RLS is powerful — but it's not magic.
It won't protect you from:
- Superuser access They bypass everything
- SQL injection (if you're careless) Especially if attackers can manipulate session variables
- Direct DB access by developers/admins If roles aren't restricted
- Broken business logic in your app RLS doesn't replace validation
- Performance issues Every query now includes additional filtering
About Performance (Let's Be Realistic)
Yes, RLS adds overhead.
But in most real systems:
- If your policy is simple (
user_id = ...) - And you index the column properly
→ the impact is usually small and acceptable
Problems only show up when:
- policies are complex
- indexes are missing
The Bigger Picture: Don't Rely on One Layer
Here's the takeaway.
RLS is not a replacement for your backend logic.
It's a second line of defense.
A solid setup looks like this:
- Backend → filters data explicitly
- Database (RLS) → enforces it again
- Roles → limit access
- Logging → helps detect issues
Because in real systems:
Bugs happen. Queries get copied. Filters get missed.
RLS is there for the moment that happens.
Simple Checklist You Can Use
- Enable RLS
- Don't use superuser for your app
- Define
USINGandWITH CHECKpolicies - Pass user context via
SET LOCAL - Test edge cases (especially missing context)
- Index your policy columns (
user_id,org_id)