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 same orders 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 of GRANT / 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 the BYPASSRLS attribute can bypass RLS.

Table owners also normally bypass RLS.

If you want it to apply to table owners too, use FORCE 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 a role 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_id are visible
  • Only rows with your own tenant_id are 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 add WHERE 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 combining exists, 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 more exists 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 use security_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 fix search_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 the tenant_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 anon and authenticated are 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 definer views/functions are unintentionally exposed
  • The default grants on the public schema 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.

References