If you use Supabase, you can't avoid Row Level Security, or RLS.
You create a table.
You read data from the client.
And at that point, almost inevitably, the talk turns to "let's write an RLS policy."
At first it looks natural.
create policy "Users can read their own rows"
on profiles
for select
using (auth.uid() = user_id);
But as you start building a somewhat more complex app, something begins to bother you.
Is this really something RLS should be doing?
RLS is a PostgreSQL feature.
But in Supabase, it has become nearly the center of API authorization for reading data directly from browsers and mobile apps.
Thinking about that, questions like these come up.
- What was RLS originally meant for?
- Is Supabase using RLS in place of an API server?
- Is it OK to push business authorization into RLS too?
- Would authorizing in the application layer be safer?
- In the end, how far should RLS be used?
In this article, I'll try to sort these out in my own way.
Conclusion
To state the conclusion first, RLS is not evil.
And it can't simply be said that Supabase's use of RLS is an "abuse never originally intended."
That said, RLS has things it's good at and things it's bad at.
RLS is good at:
Never showing rows with a different tenant_id
Showing only rows where user_id matches
Allowing reads only of rows with published = true
Enforcing, in the DB, boundaries that must not be crossed via any path
RLS alone gets painful for:
Approval flows
Billing state
Organizational hierarchies
Exceptional permissions
Business decisions spanning multiple resources
Authorization depending on external service state
What RLS is good at is enforcing data boundaries.
For example, rules like "rows of another tenant must never be visible" and "only your own rows can be updated" are a very good fit.
On the other hand, what the application is good at is complex business authorization.
If you push everything like approvals, billing, organization roles, state transitions, and exception rules into RLS, the SQL policies become hard to read, hard to test, and hard to debug.
So in practice, I think it's best to divide things like this.
Application layer:
Decides business rules, state transitions, approvals, billing, organization roles
RLS:
Enforces, on the DB side, the data boundaries that must never be crossed
Pushing everything into RLS is dangerous.
Pushing everything into the application layer alone is also dangerous.
In many cases, using application authorization as the primary mechanism with RLS as the last line of defense is easiest to work with.
What Problem Does RLS Solve?
PostgreSQL's ordinary permission management works basically at the table or column level.
grant select on orders to app_user;
revoke update on orders from app_user;
This is suited to controlling "can this table be read?" and "can this table be updated?"
But controlling "within the sameorders table, only rows of my own tenant are readable" is hard to express.
orders
Rows with tenant_id = A are for Tenant A only
Rows with tenant_id = B are for Tenant B only
RLS is the mechanism for expressing this kind of row-level access control on the DB side.
The PostgreSQL Wiki's Row-security page also explains that traditional permission control can't distinguish individual rows, which is a problem in multi-tenant, hosting, and highly sensitive environments.
So RLS is not a feature "only for multi-tenancy."
But multi-tenant isolation was clearly an important use case.
RLS Basics
Once RLS is enabled,SELECT, INSERT, UPDATE, and DELETE on that table are controlled by policies.
For example, for tenant isolation you can write:
alter table orders enable row level security;
create policy tenant_isolation_select
on orders
for select
to authenticated
using (
tenant_id = current_setting('app.tenant_id')::uuid
);
create policy tenant_isolation_write
on orders
for all
to authenticated
using (
tenant_id = current_setting('app.tenant_id')::uuid
)
with check (
tenant_id = current_setting('app.tenant_id')::uuid
);
USING decides whether an existing row is visible.
WITH CHECK decides whether a newly written row is allowed.
Roughly speaking, their roles are as follows.
USING:
Whether that row may be read
Whether that row may be selected as a target for update or delete
WITH CHECK:
Whether the new row after INSERT / UPDATE satisfies the permitted condition
If RLS is enabled and there are no policies, access is denied by default.
This is quite important.
RLS is closer to the idea of "letting through only what is explicitly allowed" than "hiding disallowed things after the fact."
RLS Is Not a Replacement for GRANT/REVOKE
RLS is not a replacement for ordinary permission control.
Rather, it's fine-grained control layered on top ofGRANT / REVOKE.
GRANT / REVOKE:
Broad permissions on tables and columns
RLS:
Given that permission, which rows may be touched
For example, if you don't have SELECT permission on a table, you can't read it even if an RLS policy exists.
Conversely, even with SELECT permission, rows are not visible unless the RLS policy allows them.
In Supabase too, the REST Data API is controlled by both grants and RLS.
"I wrote RLS, so permission design is done" is not the case.
The service role key Is a Different Thing
RLS is powerful, but it isn't always applied to everyone.
In PostgreSQL, superusers and roles with theBYPASSRLS attribute can bypass RLS.
Table owners also normally bypass RLS.
If you want it to apply to table owners too, useFORCE ROW LEVEL SECURITY.
alter table orders force row level security;
One thing to be especially careful about in Supabase is service_role.
service_role is treated as a privileged role that bypasses RLS.
Therefore, you must never expose the service role key to the frontend.
anon key:
Can be public. But assumes RLS and permission design
service role key:
Secret. Bypasses RLS, so must not be exposed to the frontend
Supabase's publishable key is not "a secret nobody may know."
What determines safety is the design of RLS, grants, exposed schemas, functions, and views.
Getting this wrong is dangerous.
Did Supabase Change RLS's Role?
I think this is the most interesting part.
PostgREST is a server that exposes PostgreSQL as a REST API.
When the JWT has arole claim, PostgREST switches to that DB role on each request.
Roughly, the flow looks like this.
Client
↓
JWT
↓
PostgREST
↓
SET LOCAL ROLE
↓
PostgreSQL
↓
Authorized by GRANT + RLS
Supabase can be seen as making this model easy to use as a product.
When you access the DB from a browser or mobile app using the Supabase client, behind the scenes it goes through PostgREST, pg_graphql, and so on, and is ultimately controlled by PostgreSQL permissions and RLS.
The thing to keep in mind here is that the browser is not connecting to PostgreSQL directly over TCP.
But by design, it offers the experience of "querying the DB directly from the client."
So RLS ends up at the center of authorization for the public API.
In that sense, I think Supabase has expanded RLS's role considerably.
Even so, calling it "abuse" right away is sloppy.
PostgreSQL RLS itself was originally a feature for enforcing row-level access control in the DB.
PostgREST/Supabase connected that to HTTP API authorization.
This is an expansion of the use, but can't be called entirely unintended.
What RLS Is Good At
What RLS is good at is enforcing simple, invariant data boundaries.
For example, rules like the following fit RLS well.
- Only rows with your own
user_idare visible - Only rows with your own
tenant_idare visible - Only published articles can be read
- Only resources of your organization can be read
- Only non-deleted rows are visible
These are conditions close to the data itself.
They are also boundaries that don't change no matter which path in the application accesses them.
Admin screen
REST API
GraphQL
Batch
SQL client
Whatever the path, never let the tenant_id boundary be crossed
Being able to enforce this kind of constraint on the DB side is a big deal.
If you authorize only in the application layer, you might forget to addWHERE tenant_id = ? in some other path.
With RLS in place, even if you forget, the DB stops it as a last resort.
This is RLS's strength.
What's Painful with RLS Alone
On the other hand, business authorization is often not simple.
For example, consider rules like these.
Owners can edit
Organization admins can also edit
Assignees can update only their own cases
Approved orders cannot be edited
Unpaid organizations cannot export
Invited users can view only part
Auditors can read only
These can't be expressed by a simple tenant_id = current_tenant_id.
User attributes, organizational relationships, the state of the target resource, billing state, approval flows, time, external service state, and so on are all involved.
Of course, if you want to, you can write them in SQL.
By combiningexists, helper functions, joins, views, triggers, and security definer functions, you can do quite complex things.
But being possible and being maintainable are different.
If you push too much complex business authorization into RLS, the following problems tend to arise.
- Policies get scattered per table
- Authorization rules are spread across SQL objects
- Hard to test
- Hard to debug
- Hard to read execution plans and performance
- You have to think about helper function permissions and even
search_path - You must check that views and functions don't bypass RLS
In the application layer, you can often write it in domain vocabulary like this.
orderPolicy.canEdit({
actor,
order,
organization,
subscription,
});
When you push it into RLS, this becomes SQL predicates scattered around.
exists (
select 1
from organization_members members
where members.organization_id = orders.organization_id
and members.user_id = auth.uid()
and members.role in ('owner', 'admin')
)
and orders.status <> 'approved'
This much is still fine.
But when this grows across every table, every operation, and exception rules, it becomes difficult to follow the whole picture.
Referencing Other Tables Makes Things Suddenly Harder
In RLS policies, you can also reference other tables.
create policy organization_member_can_read
on orders
for select
using (
exists (
select 1
from organization_members members
where members.organization_id = orders.organization_id
and members.user_id = auth.uid()
)
);
It's convenient, but once other-table references enter, it gets harder than a simple tenant_id condition.
The PostgreSQL documentation also warns about race conditions in policies that reference other tables.
Also, the moreexists and function calls there are, the more you need to check performance.
The Supabase documentation also recommends putting indexes on columns used in RLS policies, reducing the number of evaluations by tweaking things like auth.uid(), and verifying with Advisor and pgTAP.
RLS is not "safe just because you can write it."
You need to check whether the policy you wrote is correct, fast, and in a form that remains understandable in the future.
Views and Functions Are Also Authorization Boundaries
When using RLS, looking only at tables is not enough.
Views and functions also affect authorization boundaries.
In PostgreSQL, views are by default affected by the owner's permissions.
So you need to check whether access different from the RLS intent is possible via a view.
In PostgreSQL 15 and later, you can usesecurity_invoker = true to evaluate the view with the caller's permissions.
create view active_orders
with (security_invoker = true)
as
select *
from orders
where status = 'active';
Functions need the same care.
SECURITY DEFINER functions run with the owner's permissions.
They're convenient, but also an entry point for privilege escalation.
When you use them, you need to fixsearch_path, restrict execute permissions, and be clear about what may be bypassed.
Is It Safe If It's Only in the Application Layer?
If RLS is hard, should we just do everything in the application layer?
It's not that simple either.
If you put authorization only in the application layer, accidents like the following happen.
// Should originally be filtered by tenantId
const order = await db.order.findUnique({
where: {
id: orderId,
},
});
If orderId is guessable, or an ID from another tenant can be specified, it becomes a problem like BOLA / IDOR.
With RLS, even if you forget the tenant_id condition in the application layer, the DB side stops it.
using (tenant_id = current_setting('app.tenant_id')::uuid)
So it's not that not using RLS is always safer.
Rather, the more a boundary must never be crossed, the scarier it is to place it only in the application layer.
Viewing It as Something Close to Firebase Security Rules
Supabase's RLS is easier to understand when compared to Firebase Security Rules.
In Firebase Firestore, web and mobile clients access Firestore directly, and Security Rules are evaluated on the server side.
In Supabase, web and mobile clients access PostgreSQL through PostgREST and the like, and grants and RLS are evaluated on the server side.
Firebase:
Client
↓
Firestore
↓
Security Rules
Supabase:
Client
↓
PostgREST / pg_graphql
↓
PostgreSQL
↓
GRANT + RLS
Both share the philosophy of "protecting direct client access with server-side rules."
The difference is where the rules are written and the data model.
Firebase:
Custom rules based on documents / paths
Supabase:
SQL based on relational tables / rows / roles / policies
Just as writing Firebase Security Rules loosely is dangerous, writing RLS or grants loosely in Supabase is dangerous.
It's not that RLS is dangerous; rather, if you allow direct client access, the design of the server-side rules becomes the security boundary itself.
Has Danger Increased with AI App Generation?
Recent AI app-generation tools often combine BaaS such as Supabase.
That in itself is not bad.
But if non-engineers or inexperienced developers confuse publishable keys, service role keys, RLS, exposed schemas, and project public/private settings, it becomes dangerous.
Looking at primary sources, the 2026 Lovable incident should be seen not so much as "Supabase RLS was broken" but as a problem of project visibility, API authorization, and access control over source code and chat history.
However, downstream from that, Supabase credentials and secrets could leak.
In AI app generation, the following problems tend to chain together.
Misunderstanding what a public project means
↓
Source code and conversation history become visible
↓
Secrets / service keys are mixed in
↓
Paths that can bypass RLS leak
This is not a flaw of RLS itself.
But I think the danger of using a direct-client-access architecture without understanding the roles of RLS and keys has grown.
How to Divide Things in Practice
Personally, I think the following design is the most realistic.
1. Protect the tenant boundary with RLS
For a multi-tenant SaaS, protecting thetenant_id boundary with RLS is highly valuable.
using (tenant_id = current_setting('app.tenant_id')::uuid)
with check (tenant_id = current_setting('app.tenant_id')::uuid)
Check the tenant boundary in the application layer as well.
And on top of that, keep it in the DB too as the last line of defense.
2. Push complex business authorization to the application layer
Approvals, billing, state transitions, and complex organization roles go into the application layer's Policy or Domain Service.
if (!orderPolicy.canApprove({ actor, order, organization })) {
throw new ForbiddenError();
}
This is easier to read as a business rule.
It's also easier to write tests for.
3. Keep RLS to simple boundaries
Keep RLS policies as simple as possible.
Good:
tenant_id = current_tenant_id
user_id = auth.uid()
published = true
Tends to get painful:
Multiple joins
Lots of exists
Complex state transitions
Decisions involving external state
Policies full of exception rules
4. Test RLS too
RLS is SQL, so application unit tests alone can't fully verify it.
Use pgTAP, the Supabase CLI, DB migrations, CI, and so on to test the policies themselves.
At a minimum, I'd like to check the following.
- Rows of other tenants can't be read
- Rows of other tenants can't be updated
- A different tenant_id can't be inserted on INSERT
- Permissions for
anonandauthenticatedare as intended - The service role key isn't exposed in the frontend
- Views / functions don't unintentionally bypass RLS
5. Use Advisor and lint
If you use Supabase, use the Security Advisor and DB lint.
In particular, you want to detect states like the following early.
- RLS is enabled but there are no policies
- There are exposed tables with RLS disabled
- There are too many permissive policies
security definerviews/functions are unintentionally exposed- The default grants on the
publicschema are too broad
RLS isn't done once written.
You need to look at everything that grows during operation: tables, views, functions, and grants.
Summary
Is RLS being abused?
My answer is: it can't be called abuse, but it's dangerous if you expand its role too far.
PostgreSQL RLS was originally a feature for enforcing row-level access control in the DB.
Multi-tenant isolation was an important use case, but it's not the only one.
PostgREST and Supabase pushed this RLS to the center of public API authorization.
That can be said to have changed RLS's role considerably.
But it wasn't a completely unintended abuse; I think it's a design that connects the DB's permission model to HTTP APIs.
The problem is making RLS do everything.
What RLS is good at is protecting simple, invariant data boundaries.
What the application layer is good at is expressing complex business decisions in domain vocabulary.
So in practice, it's best to divide things like this.
RLS:
Protects the data boundaries that must never be crossed
Application:
Decides complex business authorization
Tests / Advisor / Review:
Verify that RLS and grants are as intended
Trusting RLS too much is dangerous.
Avoiding RLS too much is also dangerous.
What matters is not treating RLS as an all-in-one authorization engine, but making clear the boundaries the DB should protect as the last line.