Infrastructure and SEO

Supabase RLS at scale: 7 patterns for queries that stay fast

RLS slows queries 2x to 11x on large tables when policies skip the SELECT wrapper, miss indexes, or hide joins. Here are 7 patterns that fix it.

May 9, 20269 min read
Supabase RLS at scale: 7 patterns for queries that stay fast

Row Level Security (RLS) is a Postgres feature that filters rows based on policies attached to tables. On Supabase it is the standard way to enforce per-user and per-tenant access without writing server middleware. The catch is that a policy runs on every row touched by every query, so a policy written without performance in mind can turn a 5 ms query into a 500 ms one. We have seen complex RLS policies cause 2x to 11x slowdowns on large tables in production, with simple ownership checks staying close to baseline only when the underlying column is properly indexed.

This guide ships 7 patterns we apply on every Supabase project we run in production. Apply them in order; each builds on the previous one. At the end you will have policies that scale to multi-tenant tables in the millions of rows without burning your query budget.

What you need before starting

  • A Supabase project with at least one table that has RLS enabled.
  • Access to the SQL editor in the Supabase dashboard, or a connection through psql with the service role.
  • A schema with at least one tenant boundary (user, team, or organization).
  • Familiarity with EXPLAIN ANALYZE output. We rely on it to verify the changes worked.

1. Wrap auth.uid() and helper functions in a SELECT

Postgres re-evaluates a function once per row by default. Wrapping the call in a subquery promotes it to an InitPlan, so it runs once per statement and the result is cached. Supabase documents this as the single highest-impact change you can make to most policies.

-- Slow: auth.uid() runs on every row
create policy "owner can read"
on public.invoices for select
using (auth.uid() = user_id);

-- Fast: auth.uid() runs once per statement
create policy "owner can read"
on public.invoices for select
using ((select auth.uid()) = user_id);

This works for any function whose return value does not depend on the row being checked. auth.uid(), auth.jwt(), and your own SECURITY DEFINER helpers all qualify. Per-row functions, like one that hashes a column value, do not.

2. Index every column the policy reads

The policy (select auth.uid()) = user_id only stays fast if Postgres can binary-search the user_id column instead of scanning every row. On a one-million-row table, adding a btree index on user_id turns a sequential scan that touches every row into an index lookup that returns in milliseconds. Real benchmarks show over 100x improvement on large tables once the policy column is indexed.

create index invoices_user_id_idx
on public.invoices (user_id);

For multi-tenant tables, add a composite index on the columns you actually filter together: (tenant_id, status) beats two single-column indexes when both are part of every query. For partial workloads, a partial index is even better: create index ... where status = 'active' stays small and stays hot in cache.

3. Add an explicit filter on the client even when RLS would do it

RLS adds an implicit WHERE clause, which means a query like .from('invoices').select() will return only the rows the user can see. The problem is that Postgres has fewer index hints to work with when the filter is policy-only. An explicit .eq('user_id', userId) on the client lets the planner use the same index the policy relies on, instead of evaluating the policy as a final pass.

// Bad: RLS filters but the planner has no hint
const { data } = await supabase.from('invoices').select();

// Good: explicit filter mirrors the RLS condition
const { data } = await supabase.from('invoices').select().eq('user_id', userId);

It is one of those rules that feels redundant the first time you read it. It is not. Internal Supabase tests and several production reports show 15% to 60% query-time reductions on workloads where this is the only change.

4. Replace policy joins with IN or ANY subqueries from the user side

A naive multi-tenant policy joins from the source table to the membership table:

-- Slow: join evaluated per row
create policy "team members read"
on public.documents for select
using (
  auth.uid() in (
    select user_id from team_members
    where team_members.team_id = documents.team_id
  )
);

Rewrite it to fetch the user's teams once and use IN against the source table's team_id:

-- Fast: subquery resolves once, becomes a set lookup
create policy "team members read"
on public.documents for select
using (
  team_id in (
    select team_id from team_members
    where user_id = (select auth.uid())
  )
);

The first form forces Postgres to consult team_members for each candidate row. The second resolves the user's teams into a small set, then the planner uses the index on documents.team_id to filter directly. On a 200,000-row documents table backing 500 teams, we have measured this rewrite alone drop the query from 380 ms to 22 ms.

5. Move expensive lookups into SECURITY DEFINER functions

RLS policies cascade. If your policy queries another table that itself has RLS, every row check fires another set of policies, and the cost compounds. SECURITY DEFINER functions run as the role that defined them (typically postgres), which can have BYPASSRLS. A function call from inside a policy then short-circuits the cascade.

create or replace function public.user_team_ids()
returns setof uuid
language sql
security definer
stable
set search_path = public
as $$
  select team_id from team_members
  where user_id = (select auth.uid());
$$;

create policy "team members read"
on public.documents for select
using (team_id in (select * from public.user_team_ids()));

This pattern is also how you escape RLS recursion: a policy on table A that needs to read table B which has its own policy that depends on A. A SECURITY DEFINER helper breaks the loop. Audit these functions carefully because they bypass the security model you are trying to enforce; restrict their search_path and never accept untrusted input.

6. Mark stable helper functions STABLE

The STABLE volatility hint tells Postgres that the function returns the same result for the same arguments within a single statement. The planner uses this to cache and reuse the result, which compounds with the (select fn()) wrapping pattern from step 1. A function marked VOLATILE (the default) will not be cached even when wrapped.

create or replace function public.current_org_id()
returns uuid
language sql
stable          -- not volatile, not immutable
security definer
as $$
  select org_id from memberships
  where user_id = (select auth.uid())
  limit 1;
$$;

Use IMMUTABLE only if the result truly depends on nothing but the arguments (a hash function, for example). For anything that reads from a table, STABLE is the correct hint and gives the planner the cache it needs.

7. Restrict policies with TO authenticated

By default a policy applies to every role, including anon. Even when no anonymous user could ever satisfy the USING clause, Postgres still evaluates the policy for every anonymous request. Adding TO authenticated tells the planner to skip evaluation entirely for the anon role.

create policy "owner can read"
on public.invoices for select
to authenticated
using ((select auth.uid()) = user_id);

Pair this with a separate policy for service-role or admin reads if you need them. Splitting policies by role keeps each one cheap.

Verifying it works

Run EXPLAIN ANALYZE on a representative query before and after each change. The metrics that matter:

  • Plan node: a Seq Scan after step 2 means your index is missing or unusable; an Index Scan or Index Only Scan is what you want.
  • InitPlan presence: after step 1, you should see an InitPlan for auth.uid() or your helper functions, not a row-level Function Scan.
  • Actual rows vs. Plan rows: a 100x mismatch means your statistics are stale; run ANALYZE on the table.
  • Total execution time: the only number that matters at the end. If it is not under your application's budget after all 7 patterns, the policy itself may need redesigning.

To see the plan from the PostgREST API the JS client uses, enable the explain feature in development with alter role authenticator set pgrst.db_plan_enabled to true; notify pgrst, 'reload config';, then call .explain() on a query. Disable it in production.

Common failures and fixes

The policy is correct but every query times out

Check that the column referenced in the policy is indexed and that the index includes the columns the planner needs. Run EXPLAIN with BUFFERS on; a high Buffers: shared read value points to a missing index. If the index exists but is not used, your statistics are stale or the column type does not match (a text column compared to a uuid via implicit cast will not use the index).

The wrapped function pattern returns the wrong row

Double-check the function is not row-dependent. The optimization only applies when the function does not read the row being checked. A function that takes the row as input still evaluates per row.

Adding TO authenticated suddenly returns zero rows

You probably had a query running as the service role or anon. Restricting to authenticated excludes both. Add a separate policy for the service role or use the service-role client where bypassing RLS is intentional.

SECURITY DEFINER function leaks data

A SECURITY DEFINER function with BYPASSRLS returns whatever its body selects, regardless of who called it. Always filter inside the function on (select auth.uid()) or another caller-derived value, never on a function argument the caller could spoof.

Going further

RLS is one layer in a multi-tenant Supabase build. The cluster of decisions around it deserves its own reading list. We covered why every SaaS should ship multi-tenant from day one, where we treat RLS as the schema-default approach to tenant isolation. For the runtime layer that consumes those policies, edge runtime vs Node runtime covers the trade-off most production Supabase deployments hit.

Sources

Photo by Domaintechnik Ledl.net on Unsplash

Studio

Start a project.

One partner for companies, public sector, startups and SaaS. Faster delivery, modern tech, lower costs. One team, one invoice.