TL;DR: Row Level Security is not slow. Naive policies are. Postgres re-runs auth.uid() on every row unless you wrap it in a (select ...). It scans the whole table unless the policy column has an index. Wrap the call, index the column, and push shared lookups into a security definer function. Most Supabase apps get their speed back in one afternoon.

Here is the usual story. The app was built fast, often with Lovable, Bolt, or Cursor. The tables have RLS on. The demo feels quick. Then real users arrive, rows pile up, and the dashboard starts to crawl. Founders blame Supabase. The real cause is almost always the policy itself.

Want to know where your app stands first? Run the free scan on your live URL. No code access. About 30 seconds. Then come back for the fix.

QuestionAnswer
Why do RLS policies slow queries down?
Postgres re-runs functions like auth.uid() once per row.
What is the one-line fix?
Wrap the call: user_id = (select auth.uid()). It runs once per query.
Do policy columns need indexes?
Yes. Every column in a USING or WITH CHECK clause.
When do I need a security definer function?
For shared lookups, like org members. Always set search_path.
Is USING (true) a speed trick?
No. It is RLS turned off with extra steps. Anyone can read the table.
How do I check my app?
Free RLS checker on your live URL, then EXPLAIN ANALYZE.

Why naive policies are slow

You write a policy that looks right:

create policy "own rows" on todos
  for select using (user_id = auth.uid());

It passes your hand test. It guards the data. But the query planner does not know that auth.uid() returns the same value for every row. So it calls the function again and again — once per row it scans.

On a 50-row table, nobody notices. On a table with hundreds of thousands of rows, the same policy turns a snappy query into one that takes seconds. Your compute bill climbs with it. Nothing in the app changed. The table just grew.

This is the classic vibe-coding gap. The AI wrote a policy that is correct. Nobody asked if it scales. The full RLS security guide covers the safety side of this. This page covers the speed side.

The one-line fix: wrap auth.uid() in a select

Change the policy to this:

create policy "own rows" on todos
  for select using (user_id = (select auth.uid()));

That is the whole fix. The wrapped call becomes an init plan. Postgres runs it once, caches the value, and compares each row against the cached result. One function call per query instead of one per row.

It costs nothing. It needs no schema change. It does not change what the policy allows. And it is the single most common speed fix we hand to Supabase founders after an audit. If your policies call auth.jwt() or a custom claims function, wrap those the same way.

Index every column your policies filter on

The wrap buys you speed. Indexes buy you the rest. If a policy filters on user_id, Postgres still has to find the matching rows. With no index, that means a full scan of the table on every read.

create index idx_todos_user_id on todos (user_id);

Three rules of thumb:

AI builders almost never add these on their own. They generate the table, write a fair-looking policy, and move on. Nobody tells the model to read EXPLAIN ANALYZE output. So nobody does.

Security definer functions: for shared lookups only

Some policies need to check data the calling user cannot read directly. The classic case is a team app. To show a project, the policy must check that the user belongs to the org. But users should not be able to read the members table itself.

That is what security definer functions are for. The function runs with the owner's rights, not the caller's. It can read the members table safely and hand the policy a clean answer.

create function private.user_org_ids()
returns setof uuid
language sql
security definer
set search_path = ''
stable
as $$
  select org_id from public.org_members
  where user_id = (select auth.uid());
$$;

create policy "org rows" on projects
  for select using (org_id in (select private.user_org_ids()));

Two details in that snippet matter more than they look.

First, set search_path = ''. A definer function with no pinned search path can be tricked. It may read from a schema an attacker controls. That turns your speed fix into an open door. AI-generated versions of these functions almost never set it. We flag that constantly during platform-aware scans of Lovable and Bolt apps.

Second, stable. It tells the planner the function gives the same result within one query. So the result can be reused, not re-run.

USING (true) is not a performance strategy

One more pattern deserves a plain warning. Some apps "fix" slow RLS by loosening the policy to USING (true). The queries get fast. They get fast because the database stopped checking anything. Anyone with your public anon key can read every row.

That is not a tuning choice. It is the exact failure behind CVE-2025-48757, where 170+ Lovable-built apps exposed user data through missing or open policies. If you are not sure what your anon key can reach, read anon key vs service role key before you touch anything else.

The honest numbers here are not kind. Symbiotic Security scanned 1,072 AI-built apps: 98% had at least one flaw, and 16% had critical ones. Across 100 apps we scanned ourselves, the average Launch Readiness Score was 42/100. Open policies are a big reason why.

Policy patterns compared

Policy patternWhat Postgres doesVerdict
user_id = auth.uid()Calls the function once per row scannedCorrect, but slow as rows grow
user_id = (select auth.uid())Calls it once per query and caches itCorrect and fast
Wrapped call + index on user_idOne call, then an index lookupThe target state
Join to a members table inside the policyRe-runs the join logic per rowMove it to a definer function
USING (true)No filtering at allFast and unsafe. Fix it now

How to test it yourself

Do not guess. Measure, with real row counts.

First, check the plan:

explain analyze select * from todos;

Run it as a logged-in user, not as the service role. The service role skips RLS, so its plan tells you nothing. Look for two smells: a filter that names your auth function on every row, and a sequential scan on a big table. After the wrap and the index, re-run it. The plan should show an init plan and an index scan.

Second, check from the outside. Our free Supabase RLS checker tests your live URL: which tables have RLS off, and which policies let anyone read. The browser version runs the same checks. Neither needs code access.

Third, load matters too. A policy that holds up for one user can fold under fifty. Our scan tests endpoints under heavy load, and the rate limit checker covers the same gap on your API routes.

One legal note

A slow policy and a leaky policy are often the same bug wearing two hats. A policy too loose to need an index is also too loose to keep one user's data away from another's. That is a GDPR problem, not just a speed problem. If your product also has an AI feature and EU users, the EU AI Act's Article 50 transparency rules apply from August 2, 2026. Fines for that breach run up to €15 million or 3% of turnover. Our Compliance Wing checks both sides in one pass.

What the full check costs

The free scan is $0. It returns a Launch Readiness Score out of 100 for your live app in about 30 seconds. The full Launch Readiness Audit is $499 one time: a branded report, a ranked fix plan with time estimates, and a benchmark against 200+ audited apps. Re-scans after each deploy start at $149/mo. More Supabase checks live on our Supabase security audit page.

FAQ

Does Row Level Security slow down Postgres?

Not by itself. A well-written policy with a wrapped auth.uid() call and an index on the filtered column runs close to a hand-written WHERE clause. RLS gets slow when the policy re-runs a function per row or scans a table with no index.

Why is my Supabase app slow with RLS enabled?

The most common cause is an unwrapped auth.uid() call inside the policy. Postgres re-runs it on every row it scans. The second most common cause is a missing index on the column the policy filters on. Fix both and most of the slowdown goes away.

What does wrapping auth.uid() in a select do?

It turns the call into an init plan. Postgres runs it once per query, caches the value, and reuses it for every row. Without the wrap, the planner treats it as a call it must repeat per row. The change is one line and needs no schema change.

Do I need indexes for RLS policies?

Yes. Any column that appears in a USING or WITH CHECK clause should be indexed. Without one, Postgres scans the whole table and applies the policy row by row. A B-tree index on user_id or org_id is usually enough.

When should I use a security definer function?

Use one when a policy must check data the calling user cannot read directly, like an org members table. The function runs with the owner's rights and caches the lookup. Always set search_path in the function, or it can hand out rights the caller should not have.

How do I test my own RLS setup?

Run EXPLAIN ANALYZE on your hot queries as a logged-in user. Look for per-row function calls and full-table scans. Then run the free Supabase RLS checker on your live URL. It flags tables with RLS off and policies that let anyone read.

The pattern is the same one we see across every stack in the RLS policies guide: the AI ships a policy that works, and leaves the part that scales to you. Wrap the call. Index the column. Measure the plan. Then scan the whole app, because slow policies rarely travel alone.

Is your database the slow part, or the open part?

Scan your live app free. Launch Readiness Score out of 100, in about 30 seconds. No code access.

Run the free scan