If you use Supabase, there is no getting around Row Level Security, or RLS. Create a table, try to read it from the client, and almost without exception the conversation turns to "let's write an RLS policy." At first, that seems perfectly natural.

create policy "Users can read their own rows"
on profiles
for select
using (auth.uid() = user_id);

Once you start building something a little more involved, though, a nagging question creeps in. Is this really RLS's job?

RLS is, after all, a PostgreSQL feature. Yet in Supabase it sits enthroned at the center of API authorization, the thing that lets browsers and mobile apps read data directly.

Which raises a string of questions. What was RLS originally for? Is Supabase using it in place of an API server? Does business authorization belong there too? Would authorizing in the application layer be safer? And in the end, how far should RLS be used at all?

Let me state my position up front. RLS is not evil. Supabase's use of it can't simply be written off as "abuse never originally intended," either. But RLS has things it is good at and things it is bad at.

What It's Good At, and What It Isn't

What RLS is good at is enforcing data boundaries. Never show rows with a different tenant_id. Show only rows where user_id matches. Allow reads only of rows with published = true. Guarding boundaries that must not be crossed via any path is where RLS is in its element.

What about approval flows, billing state, organizational hierarchies, exceptional permissions? Then there are business decisions spanning multiple resources, and authorization that depends on external services. Carry those in RLS alone and things get painful fast, because this is complex business authorization, the application's home turf. Push all of it into RLS and your SQL policies turn into contraptions that are hard to read, test, or debug.

So here is how I divide it. Decisions about business rules, state transitions, approvals, billing, and organization roles go in the application layer. Enforcement of the data boundaries that must never be crossed goes to RLS. Pushing everything into RLS is dangerous, and so is pushing everything into the application layer. In most cases, application authorization as the primary mechanism, with RLS as the final lock on the door, is the easiest arrangement to live with.

Row-Level Access Control

PostgreSQL's ordinary permission management works, by and large, at the level of tables and columns.

grant select on orders to app_user;
revoke update on orders from app_user;
That's well suited to controlling "can this table be read?" and "can it be updated?" It's a different story when you want each row in the same orders table to have its own owner. Rows with tenant_id = A for Tenant A only, rows with tenant_id = B for Tenant B only: that is hard to express with this mechanism. RLS is the mechanism for writing exactly that kind of row-level access control on the DB side.

The PostgreSQL Wiki's Row-security page says much the same thing. Traditional permission control can't distinguish individual rows, which becomes a problem in multi-tenant, hosting, and highly sensitive environments. In other words, RLS is not a multi-tenancy-only feature. That said, multi-tenant isolation was clearly an important use case.

USING and WITH CHECK

Once RLS is enabled, SELECT, INSERT, UPDATE, and DELETE on that table are controlled by policies. For tenant isolation, you might write something like this.
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: whether it may be read, and whether it may be picked as a target for update or delete. WITH CHECK, by contrast, looks at whether the new row after an INSERT or UPDATE satisfies the permitted condition.

Note that if RLS is enabled and there are no policies at all, access is denied by default. This is a crucial property. RLS is closer to "let through only what is explicitly allowed" than to "hide disallowed things after the fact." It's like locking the house and then handing out spare keys one at a time.

Layered on Top of GRANT

RLS is not a replacement for ordinary permission control. It is fine-grained control layered on top of GRANT / REVOKE. GRANT / REVOKE set the broad permissions on tables and columns. RLS then decides, given those permissions, which rows may be touched. So if you lack SELECT permission on a table, you can't read it even if an RLS policy exists. Conversely, even with SELECT permission, rows stay invisible unless an RLS policy allows them. Supabase's REST Data API is likewise controlled by both grants and RLS. Writing RLS does not mean your permission design is finished.

The service role key Is a Different Animal

RLS is powerful, but it doesn't apply to everyone all the time. In PostgreSQL, superusers and roles with the BYPASSRLS attribute can bypass it. Table owners normally bypass it too, so if you want it to apply to owners as well, you use FORCE ROW LEVEL SECURITY.
alter table orders force row level security;
The thing to be especially careful about in Supabase is service_role. It's treated as a privileged role that bypasses RLS. Exposing the service role key to the frontend is out of the question. The anon key can be public, but only on the premise that RLS and permissions are designed properly. The service role key walks straight past RLS, which makes it a secret in the fullest sense.

Put the other way around, 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. Get that wrong and you've locked the front door while leaving every window wide open.

Did Supabase Change RLS's Role?

This is the most interesting part. PostgREST is a server that exposes PostgreSQL as a REST API. When a JWT carries a role claim, PostgREST switches to that DB role on each request. The flow goes like this: the client sends a request with a JWT, PostgREST switches roles with SET LOCAL ROLE, and finally PostgreSQL authorizes it with GRANT and RLS.

Supabase can fairly be seen as packaging this model into something easy to use as a product. When you access the DB from a browser or mobile app via the Supabase client, the request passes through PostgREST, pg_graphql, and so on behind the scenes. In the end, it is governed by PostgreSQL permissions and RLS.

The point to keep in mind is that the browser is not connecting to PostgreSQL directly over TCP. By design, however, it offers the experience of "querying the DB directly from the client." That's why RLS ends up at the center of authorization for the public API. In that sense, Supabase stretched RLS's role considerably.

Even so, calling that "abuse" outright is sloppy. PostgreSQL RLS was always a feature for enforcing row-level access control in the DB. PostgREST/Supabase merely connected it to HTTP API authorization. It's an expansion of its use, but you can't call it entirely unintended.

A Boundary That Ignores the Path

What RLS is good at is enforcing simple, invariant data boundaries. Only rows with your own user_id are visible. Only rows with your own tenant_id are visible. Only published articles, your organization's resources, and non-deleted rows can be read. These are all conditions close to the data itself, boundaries that stay the same whichever path through the application you come in by. From the admin screen, the REST API, or GraphQL, the tenant_id boundary must never be crossed. The same goes for a batch job or a SQL client. Being able to enforce that on the DB side is a big deal. If you authorize only in the application layer, someone may forget WHERE tenant_id = ? on some other path. With RLS in place, the DB stops it at the last moment even if they forget. The house itself refuses entry through the back door you left unlocked.

Business Authorization Is Not Simple

Business authorization, however, is often anything but simple. Owners can edit. Organization admins may edit too. Assignees may update only their own cases.

Approved orders are off-limits for editing. Unpaid organizations can't export. Invited users can view only part of things, and auditors are read-only. Just listing them makes my stomach sink.

None of that can be expressed with a plain tenant_id = current_tenant_id. User attributes, organizational relationships, the state of the target resource, billing state, approval flows, time, and external service state all get tangled in. Of course, you can write it in SQL if you're determined. Combine exists, helper functions, joins, views, triggers, and security definer functions, and you can do surprisingly complex things. But possible and maintainable are two different things. Push too much complex business authorization into RLS and policies scatter across tables, with authorization rules overrunning every SQL object you own. Testing and debugging get harder, and so does reading execution plans and performance. You end up thinking about helper function permissions and even search_path. And you can never skip checking whether views and functions are bypassing RLS.

In the application layer, you can usually write it in domain vocabulary like this.

orderPolicy.canEdit({
  actor,
  order,
  organization,
  subscription,
});

Push the same thing into RLS and it scatters into SQL predicates.

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 what happens once it multiplies across every table and every operation, exceptions included? Following the big picture becomes a feat of endurance.

The Trouble with Referencing Other Tables

An RLS policy 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 the moment another table enters the picture, things get messier than a simple tenant_id condition. The PostgreSQL documentation includes a warning about race conditions in policies that reference other tables. The more exists clauses and function calls you add, the more performance you need to check, too. Supabase's documentation recommends three things. Index the columns used in RLS policies. Write things like auth.uid() so they're evaluated fewer times. Verify with Advisor and pgTAP.

In short, RLS is not safe just because you managed to write it. Is the policy correct? Is it fast? Can your future self still read it? Only once you've checked all that does it pull its weight.

Views and Functions as Back Doors

If you use RLS, looking only at tables isn't enough. Views and functions affect the authorization boundary too. PostgreSQL views are by default subject to the owner's permissions, so you need to check that a view doesn't allow access the RLS was meant to prevent. From PostgreSQL 15 onward, specifying security_invoker = true evaluates the view with the caller's permissions.
create view active_orders
with (security_invoker = true)
as
select *
from orders
where status = 'active';
Functions are the same story. A SECURITY DEFINER function runs with its owner's permissions, which makes it convenient and also an entry point for privilege escalation. If you use one, pin search_path and restrict execute permissions. You also have to be explicit about what it is allowed to bypass.

Is the Application Layer Alone Safe?

If RLS is hard, why not just do everything in the application layer? It isn't that simple either. Put authorization only in the application layer, and accidents like this 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 supplied, you have a BOLA / IDOR-style problem. With RLS, even if the application layer forgets the tenant_id condition, the DB side stops it.
using (tenant_id = current_setting('app.tenant_id')::uuid)

So skipping RLS is not always the safer choice. If anything, the more a boundary must never be crossed, the scarier it is to leave it to the application layer alone.

Side by Side with Firebase Security Rules

Supabase's RLS is easier to grasp next to Firebase Security Rules. In Firestore, web and mobile clients access Firestore directly, and Security Rules are evaluated on the server side. In Supabase, clients reach PostgreSQL through PostgREST or pg_graphql, and GRANT and RLS are evaluated on the server side.

FirebaseSupabase
What the client connects toFirestorePostgreSQL via PostgREST / pg_graphql
Server-side rulesSecurity RulesGRANT + RLS
How rules are writtenCustom rules based on documents / pathsSQL based on relational tables / rows / roles / policies

Guard direct client access with server-side rules: both share that philosophy, and what differs is where the rules are written and the data model. Just as loosely written Firebase Security Rules are dangerous, loosely written RLS or grants in Supabase are dangerous. It's not that RLS is dangerous. Allow direct access, and the design of your server-side rules becomes the security boundary itself. That's all there is to it.

AI App Generation and Chained Failures

Recent AI app-generation tools often pair up with a BaaS like Supabase. There's nothing wrong with that in itself. But when non-engineers or inexperienced developers confuse publishable keys, service role keys, RLS, exposed schemas, and a project's public/private settings, it's another matter. Things turn precarious fast.

Going by the primary sources, the 2026 Lovable incident doesn't look like a case of "Supabase RLS was broken." It's better seen as a problem of project visibility, API authorization, and access control over source code and chat history. That said, Supabase credentials and secrets could leak downstream from there. Someone misunderstands what a public project means. Source code and conversation history become visible. If secrets or service keys are mixed in, a path that bypasses RLS leaks too. With AI app generation, problems like these tend to string together like beads.

This is not a flaw in RLS itself. But the danger of using a direct-client-access architecture without understanding the roles of RLS and keys has clearly grown.

Dividing It Up in Practice

Personally, I think the following design is the most realistic.

First, protect the tenant boundary with RLS. For a multi-tenant SaaS, guarding the tenant_id boundary with RLS is well worth it. Check it in the application layer as well, and on top of that keep it in the DB as the final lock.
using (tenant_id = current_setting('app.tenant_id')::uuid)
with check (tenant_id = current_setting('app.tenant_id')::uuid)

Next, push complex business authorization to the application layer. Approvals, billing, state transitions, and tangled organization roles go into the application layer's Policy or Domain Service. They read better as business rules there, and they're easier to test.

if (!orderPolicy.canApprove({ actor, order, organization })) {
  throw new ForbiddenError();
}
Then keep RLS policies as simple as possible. Conditions like tenant_id = current_tenant_id, user_id = auth.uid(), or published = true are fine. Reach for multiple joins, piles of exists, complex state transitions, or decisions involving external state, and things tend to get painful. A policy riddled with exceptions is, frankly, something I'd rather not sign up for.

After that, 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, here's what I want to confirm. Other tenants' rows can't be read or updated. A different tenant_id can't be inserted. anon and authenticated permissions are as intended. The service role key isn't exposed in the frontend. Views and functions aren't unintentionally bypassing RLS. Finally, if you use Supabase, lean on the Security Advisor and DB lint. RLS enabled but no policies. Exposed tables with RLS disabled. Too many permissive policies. security definer views or functions exposed by accident. Overly broad default grants on the public schema.

You want to catch these early. RLS isn't done once written. You have to keep watch over the tables, views, functions, and grants that pile up during operation.

Expansion, Not Abuse

So, is RLS being abused? My answer: it can't be called abuse, but stretching its role too far is dangerous.

PostgreSQL RLS was originally a feature for enforcing row-level access control in the DB. Multi-tenant isolation was an important use case, but not the only one. PostgREST and Supabase pushed RLS to the center of public API authorization, and its role changed considerably as a result. But it wasn't an unintended abuse; 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 guarding simple, invariant data boundaries. What the application layer is good at is expressing complex business decisions in domain vocabulary. And whether RLS and grants are doing what you intended gets verified through tests, Advisor, and review. Keep those three separate, and you'll neither trust RLS too much nor avoid it too much.

That's where I've landed on RLS's original purpose, how Supabase uses it, and how to split the work with application authorization. RLS isn't a butler who handles every decision in the house; it's the lock you turn last. Deciding where that lock goes is, in the end, still up to us.

References