Enabling row-level security answers one question: which rows can this request see? It says nothing about a function that runs with different privileges, a column that shouldn’t be in the response at all, or a process that takes several writes to finish correctly. Turning RLS on is necessary, and it’s also the easiest part of getting a Supabase app’s authorization right, which is exactly why so many founders stop there.
On its own, Supabase RLS isn’t enough for a production app. Three specific gaps survive a correct policy: functions that run around it, columns it can’t hide, and multi-step processes it can’t see. All three come from real audited apps, and each gets a concrete check before this post ends.
What RLS actually decides
A row-level security policy is a filter Postgres applies to a query: given this role and this row, does the condition in using or with check come out true? That’s the entire job. It doesn’t know what your application is trying to accomplish, and it runs once per row, per statement, wherever RLS applies, not once for the whole request.
Two Supabase RLS limitations follow from that, and both matter more than the enable switch itself.
Rows, not columns. A policy can let a user read a row and still hand them a column that should have stayed private. A profiles policy scoped to “same organization” says nothing about the stripe_customer_id column sitting next to the name and avatar, so every coworker reads every other coworker’s billing identifier through the same allowed row. The fix is a column-level GRANT, or a view that excludes the field; RLS by itself isn’t it.
One statement, not one process. “Approve this refund” is rarely one row in one table; it’s a status flip, a ledger write, and maybe a webhook call. A policy that holds on each table says nothing about whether the sequence is right, or whether a client can call step two without ever being allowed to trigger step one.
The bypass a static linter can’t see
Supabase’s Database Advisor will tell you whether a table has RLS enabled, and it flags more than that: lint 0010 catches a view defined with SECURITY DEFINER, and lints 0028 and 0029 catch a SECURITY DEFINER function that the anon or authenticated role can execute. What a linter reads is the object’s definition, never the function body, so it can tell you a policy-skipping object exists and is exposed, and it cannot tell you whether that object checks who’s calling before it hands rows back. Postgres gives you a legitimate reason to build exactly that kind of object.
A function declared security definer runs with the privileges of whoever owns it, not the privileges of whoever calls it. That’s a deliberate, legitimate feature, the same reason a Unix setuid binary exists: to let someone unprivileged trigger a privileged action safely. Whether it stays safe depends entirely on what the function checks once it’s running, and a generator that reaches for security definer to solve an access problem often stops one step short: it grants the elevated privilege, then trusts whatever id the caller hands it instead of comparing that id to who’s actually signed in.
-- Runs with the privileges of the function's owner.
-- Row-level security on `invoices` does not apply inside this function.
create function get_team_invoices(p_team_id uuid, p_requested_by uuid)
returns setof invoices
language sql
security definer
as $$
select * from invoices where team_id = p_team_id;
-- p_requested_by is accepted as an argument and never compared
-- to auth.uid(). The caller is trusted to say who they are.
$$;
I’ve read schemas where the table-level policy was genuinely correct and holding, then found a function two objects over doing exactly this: taking a team id or a user id as a plain argument, running as the table owner, and never once checking it against the session that called it. In Supabase, a function like this is usually reachable straight from the client as an auto-generated RPC endpoint, so the fix isn’t “add a policy,” because security definer never asks the policy anything in the first place. The fix is inside the function body:
create function get_team_invoices(p_team_id uuid)
returns setof invoices
language sql
security definer
as $$
select * from invoices
where team_id = p_team_id
and exists (
select 1 from team_members
where team_id = p_team_id and user_id = auth.uid()
);
$$;
Same privilege, same shortcut around RLS, but now the function checks ownership itself instead of assuming the caller already did. A table-level RLS review reads the invoices policy, confirms it’s correct, and moves on, with no reason to open get_team_invoices at all.
RLS decides who can touch a row. It says nothing about whether the function that touches it forgot to check.
Across the AI-built apps I’ve audited, this isn’t a one-off: security definer functions and views built without security_invoker are the recurring way a solid table policy sits exactly as written while the request that matters routes around it at the source and never reaches it. Row-level-security gaps of some kind turned up in 9 of 21 third-party apps in the AxonBuild corpus, audited between June and July 2026, and the shape holds regardless of which generator wrote the schema: a policy narrow enough to be right, sitting beside a helper function or view that the policy was never in a position to stop. It’s the same trust boundary as the rest of the holes AI coding tools ship by default: a check for who’s signed in, doing the job a check for what they’re allowed to touch was supposed to do.
When “logged in” isn’t “allowed”
The trap has a mirror image that never involves a bypass at all: a request every policy permits, that the business still shouldn’t allow. A team member can read a project’s data by every policy you wrote, and it’s still wrong for them to export it once, from every project, in one afternoon. None of that is a failure RLS was ever built to catch, because each individual read landed on a row the policy rightly says they own. What was wrong was the pattern across several permitted actions, and a per-row filter has no concept of a pattern.
One developer’s write-up on exactly this, published in June 2025, puts it plainly: past the simplest shape, Supabase’s RLS “starts collapsing under its own cleverness,” and “you can’t really rely on auth policies in Supabase” for everything an app needs to enforce. The conclusions part ways: that piece’s fix reaches for a backend layer, while the fix here keeps the row filter and moves only the pattern-across-actions checks into application logic that can see the whole request.
Where the generic advice is actually right
Every tutorial that tells you to enable RLS on day one has this right, worth saying plainly since the rest of this post has been about where that advice runs out. A table with RLS off, reachable through Supabase’s public API with the anon key, is worse than any failure above, the difference between a lock that’s picked and a door that was never closed. Writing a policy that checks the caller’s own id against the row is the floor, not the ceiling, and skipping it is the whole mistake, not a subtlety.
There’s a narrower, true claim inside that same advice. On a 2023 Ask HN thread raising this exact question, the reply came from kiwicopple, the account Supabase’s own co-founder posts under everywhere else: “If you’re using pure react (client side) with the Supabase APIs, then RLS is all you need (just make sure you are using the anon key, not the service_role key).” Taken as written, that holds for the app it describes: no server route, no edge function, no security definer helper anywhere in the schema. Almost nothing stays that shape for long, and the moment a project adds one server action or one privileged RPC, the claim keeps being true only for the sliver that never grew one.
Once RLS is on, the question worth asking is what, in this schema, still runs without RLS ever being consulted, and whether that was the plan.
How to know RLS is actually enough for your app
A policy that reads correctly on the page isn’t proof of anything. The only way to know whether it holds is to enumerate everywhere a request can reach your data other than a plain table query, and test each one the same way: as a second, distinct account trying to reach the first account’s row.
- 01 List every function in your schema marked security definer, and for each one, find the line that compares an argument to auth.uid(). If there isn’t one, the function is the policy now, not the table.
- 02 List every view built on a table that has RLS, and confirm it was created (or altered) with security_invoker = true; a view built the old way runs as its owner and reads straight past the policies on its underlying tables.
- 03 Create a second real account. Log in as it, and try every RPC and privileged action the first account can reach, beyond the plain table reads a REST client generates automatically.
- 04 For anything that takes more than one write to finish (an approval, a refund, a plan change), check whether calling step two directly, without step one, produces a result a reviewer would call wrong.
- 05 Re-run the same tests after any migration that touches a security definer function, a view, or a policy; a passing check today says nothing about the one a later prompt quietly reworded.
That last step is the one a demo never runs on its own, because a demo is one person, one account, one happy path through the schema. The Beyond the Demo Audit is built for exactly that gap: an audit that traces every policy and every SECURITY DEFINER function against the real call paths your app runs instead of your memory of it.
This post stays inside one narrow failure: a policy that’s right and still isn’t the whole answer. Whether Supabase is safe for your app at all is the wider question this is one piece of, and it assumes the earlier job is already done: after you’ve written the policies correctly, this is what they still can’t do. Two more basic failures sit next to this one and aren’t it. If RLS was never turned on, or a policy exists that never checks ownership, that’s the read-side proof when RLS is off entirely, the database handing out rows to anyone holding the anon key. If Postgres answers with “new row violates row-level security policy” instead, a too-strict policy is throwing instead of a missing one, the opposite mistake. The same shape shows up on a different surface too: a control that looks complete because the obvious door is locked while a side door goes unchecked is what lets a checkout hand the browser the final say on who paid just as easily as it lets a function skip a policy. The full counted RLS-gap rate sits tallied once, in one place, across the whole audit program.
RLS enabled and solid on every table you can name rules out the loudest failure: a stranger reading your whole database with the anon key. It was never going to rule out the quieter one, a function or a process the policy was never positioned to check, and closing that gap is one piece of whether an AI-built app is actually ready to launch.
Common questions about Supabase RLS
Is Supabase RLS enough for production?
Enabling it is necessary, not sufficient. A policy only governs plain table access; a security definer function, a view without security_invoker, or a multi-step process can all reach the same data without the policy ever being asked. Treat RLS as the floor, and verify the rest with a second account, not a policy read.
Do I still need backend logic with Supabase RLS?
Yes, for anything beyond “does this row belong to the caller.” Column-level exposure, cross-row business rules, and any action that takes more than one write to complete correctly are outside what a row filter can check, and belong in application code that can see the whole request.
Can RLS protect against a SECURITY DEFINER function?
No, by design. A security definer function runs with its owner’s privileges and never evaluates the caller’s row-level-security policies at all, which is the entire point of the feature. Whatever authorization that function needs has to be written inside its own body, usually a direct comparison against auth.uid().
Does RLS handle column-level security?
No. A using or with check clause filters which rows come back; it has no mechanism for hiding one column while allowing the rest of the same row. Excluding a column needs a column-level GRANT, a view that omits the field, or a query that never selects it in the first place.
Does RLS cover business logic?
No. A policy checks one condition against one row in one statement. A business rule that spans several rows, several tables, or several steps in sequence (an approval, a refund, a subscription change) isn’t something a row-level-security policy was designed to evaluate at all, and needs an explicit check in application code instead.
Don't guess where you stand.
The free 2-minute Beyond the Demo Scorecard estimates where you stand across 12 readiness areas and shows what to check first.