RLS (Row Level Security) — the wall your database needs that almost nobody builds

There's a type of bug that doesn't break the build, doesn't show up in the logs, doesn't land in Sentry, but kills your product overnight: user A opening the application and seeing — out of nowhere — user B's data. Transaction, balance, tax ID, address, whatever. And the worst part: in almost every project where I saw this happen, the problem wasn't "a bug" in the code. It was the total absence of RLS in the database.
Today I want to talk about Row Level Security, which is literally the wall that separates one user's data from another's — within the same table.
With vibe coding on fire (which is basically asking AI to code everything and pushing features without looking much under the hood), this has become even more common. People spin up a Supabase instance, create some tables, connect to the frontend and think they're done. Except they forgot to lock the door. So anyone with the public key can run SELECT * FROM users and walk away with the entire database.
It's not an exaggeration. I've seen it.
So, what is RLS?
Row Level Security is a native PostgreSQL feature (and inherited by platforms like Supabase, Neon, etc.) that allows you to apply access rules at the row level. Instead of controlling access only in the application code, you push the control down to the database itself.
It works roughly like this: you enable RLS on the table, create policies describing who can read, write, update, and delete what, and Postgres starts filtering every query automatically — without the developer needing to remember this in every WHERE clause.
In a world without RLS, every table is like a room with an open door. Whoever reaches the database with a valid key (your anon key, for example) can, in theory, read everything. What protects it is your application — and if it fails on one endpoint, it's game over.
With RLS, the door is locked by default. Even if the frontend asks for everything, the database only delivers what the user in that session has the right to see.
Think of RLS as the database's seatbelt. It's not what drives well — but when there's a crash, it's what saves you.
Why this has become urgent (not just a curiosity)
If you look at any multi-tenant project — SaaS, financial platform, collaborative tool — each customer's data lives alongside everyone else's. Usually in a user_id, tenant_id, organization_id column, those kinds of things.
If protection is only on the frontend or only in the API layer, one endpoint that forgets to filter and all of every customer's data leaks. And if you expose the database directly to the client (standard Supabase, Firebase, etc. pattern), your only line of defense becomes the database rule. In other words: without RLS, there's no defense.
You see these people spinning up SaaS products in 3 days using AI and pushing them live? Many of those projects have public tables behind the anon key. I've opened the console on some "products" and been able to list users that weren't mine. This isn't paranoia — it's the real scenario.
Vibe coding accelerates delivery, but also accelerates the number of security holes going to production. And LGPD/GDPR doesn't care if you "didn't know."
The classic problem: "but I filter on the frontend"
This argument comes up every single time. "Oh, but my query already has where user_id = currentUser, so it's secure."
It's not. And I'll show you why.
// client code — runs in the browser, anyone can inspect
const { data } = await supabase
.from('transactions')
.select('*')
.eq('user_id', currentUser.id) // "safe", right? 🙃
Anyone with DevTools open can:
- Replace
currentUser.idwith any other ID. - Remove the
.eq()entirely and do aSELECT *. - Use the
anon keydirectly in acurlrequest to hit the Supabase/PostgREST REST endpoint.
Without RLS, the database serves everything. Because from its perspective, the key is valid and nobody said that row is forbidden.
The only thing that permanently breaks this is a policy in the database saying: "from this table, you can only see rows where user_id = auth.uid()."
In practice: enabling RLS in Postgres/Supabase
Let's look at the example I see most often: a transactions table where each user should see only their own rows.
-- 1. Enable RLS on the table (disabled by default)
alter table transactions enable row level security;
-- 2. Read policy: authenticated user can only read their own rows
create policy "select_own_transactions"
on transactions
for select
to authenticated
using (auth.uid() = user_id);
-- 3. Insert policy: user can only insert linked to their own id
create policy "insert_own_transactions"
on transactions
for insert
to authenticated
with check (auth.uid() = user_id);
-- 4. Update/delete policy: same logic
create policy "update_own_transactions"
on transactions
for update
to authenticated
using (auth.uid() = user_id)
with check (auth.uid() = user_id);
create policy "delete_own_transactions"
on transactions
for delete
to authenticated
using (auth.uid() = user_id);
Two important things here:
usingis the read rule (which rows I can see);with checkis the write rule (which rows I can create/edit).
If you forget with check on insert/update, the user can create a row on behalf of someone else and bypass the policy. Small detail, huge consequence.
Multi-tenant: when the filter isn't by user
In B2B SaaS, the filter is usually by organization. A common approach:
alter table invoices enable row level security;
create policy "select_invoices_same_org"
on invoices
for select
to authenticated
using (
organization_id in (
select organization_id
from memberships
where user_id = auth.uid()
)
);
Here the policy says: you can only see invoices from an org where you're listed as a member. Rule in the database, not on the frontend. Heavier subqueries can cost performance — so add an index on memberships(user_id, organization_id) and measure.
Role-based: admin sees everything, regular user sees their own
You can combine RLS with custom roles (via JWT claim in Supabase, for example):
create policy "admins_see_all"
on transactions
for select
to authenticated
using (
auth.jwt() ->> 'role' = 'admin'
or auth.uid() = user_id
);
The or is the trick — admin sees everything, everyone else sees their own. Just be careful about who issues the JWT and what goes in the claim, because if the frontend can manipulate this, security is gone.
