Supabase RLS at Scale: When Row-Level Security Becomes a Performance Problem
Why Supabase RLS policies get slow past a few hundred thousand rows, how to spot it with EXPLAIN ANALYZE, and the indexing and caching fixes that work.

Row Level Security (RLS) is how Supabase secures data by default: every table with RLS enabled filters rows per user directly inside Postgres. It works well until a table crosses a few hundred thousand rows and a query that used to return instantly starts timing out. That's the most common Supabase RLS performance problem: a policy that reads correctly but forces Postgres to redo an authorization check, or a join, on every row instead of once per query. This post covers why that happens at the query-planner level, how to spot it with EXPLAIN ANALYZE, and the fixes — indexing, initPlan caching, security definer functions, pooling, and Realtime-specific mitigations — that take real queries from seconds to single-digit milliseconds.
Quick Answer
Supabase Row Level Security gets slow at scale for three core reasons: unindexed columns force sequential scans, calling auth.uid() or auth.jwt() unwrapped re-evaluates the function on every row instead of once per query, and membership checks through another table run that join per row — a fourth cause compounds under load, since Realtime's postgres_changes re-runs RLS once per subscriber for every row change. Fix the first three by indexing policy columns, wrapping auth functions in (select ...), and moving cross-table checks into indexed security definer functions; fix the fourth by keeping those same policies cheap and considering Broadcast for high-fanout Realtime use cases.
What Is Row-Level Security?
RLS is a native PostgreSQL feature that Supabase uses as its primary authorization model instead of a middle-tier permissions layer.
- It attaches a USING (read) and/or WITH CHECK (write) boolean expression to a table via CREATE POLICY.
- Postgres appends that expression as an implicit filter on every SELECT, INSERT, UPDATE, and DELETE against the table, including requests routed through Supabase's auto-generated PostgREST API.
- Policies typically check auth.uid() and auth.jwt(), which read the JWT claims Supabase's API injects into the Postgres session for each request.
- It's the correct default whenever a browser or mobile client talks to Postgres directly, because authorization lives in the data layer, where a missing WHERE clause in application code can't bypass it.
RLS is a security feature that lives inside the query planner, not a query-optimization feature. Every performance problem in this post comes from that distinction.
Permissive vs. Restrictive Policies
Postgres supports two policy kinds, and how they combine matters for both correctness and performance:
| Policy type | Declared with | How multiple policies combine |
|---|---|---|
| Permissive (default) | create policy ... as permissive ... (or omit as) | Combined with OR — access is allowed if any permissive policy passes |
| Restrictive | create policy ... as restrictive ... | Combined with AND — access is denied if any restrictive policy fails |
Every permissive policy for a given role and action still gets evaluated, even after one has already granted access, because Postgres can't know in advance which one will pass. That's why Supabase's Performance Advisor flags multiple_permissive_policies: two policies doing the job of one costs double the per-row evaluation, not free redundancy. Restrictive policies are useful for adding a hard constraint (like an "account is not suspended" check) on top of permissive ones, without duplicating the ownership logic in every permissive policy.
Why Wrapping in (select ...) Works
Postgres classifies every function by volatility, and that classification is the entire reason wrapping auth.uid() in (select ...) changes performance without changing behavior.
VOLATILE: may return a different result on every call, even within the same statement (for example random(), and PL/pgSQL functions by default). The planner has to call it fresh every time it's referenced.STABLE: guaranteed to return the same result for the same arguments within a single statement, though it can differ between statements. auth.uid() and auth.jwt() are marked STABLE.IMMUTABLE: always returns the same result for the same arguments, period.
A STABLE function is allowed to be cached once per statement, but Postgres only does that caching automatically when it can pull the function call out as a separate step that runs before the main scan, called an InitPlan. A bare auth.uid() = owner_id inside a USING clause compiles into a per-row filter, and the planner leaves the function call embedded inside that filter, re-invoking it for every row the scan considers, even though the result never changes. Wrapping it as (select auth.uid()) turns it into a scalar subquery. Postgres recognizes that the subquery doesn't depend on anything from the outer row, hoists it out as an InitPlan, evaluates it exactly once, and substitutes the cached value into the row filter for every row after that.
You can see this difference directly in EXPLAIN ANALYZE output. An unwrapped call never appears as its own plan node; it's buried inside Filter:. A wrapped call shows up explicitly, as its own plan node, before the scan even starts:
InitPlan 1 (returns $0) -> Result (cost=0.00..0.01 rows=1 width=16) (actual time=0.001..0.002 rows=1 loops=1)Index Scan using idx_documents_owner_id on documents (cost=0.29..8.31 rows=1 width=712) (actual time=0.006..0.007 rows=1 loops=1) Index Cond: (owner_id = $0)
That InitPlan 1 line, computed once, accounts for the entire difference between hundreds of milliseconds and single-digit milliseconds on a large table. The trick only works because auth.uid() is STABLE and doesn't reference the outer row. Never apply it to a function whose result depends on per-row data; that would compute the wrong answer, not just a faster one.
The Problem
A policy like this looks completely reasonable:
create policy "Users can view their own documents"on documentsfor selectusing ( auth.uid() = owner_id );
On a table with a few thousand rows, it's instant. On a table with a few million rows, the identical policy can turn a sub-millisecond query into one that takes seconds. It gets worse under concurrent load, because the extra per-row CPU work competes with every other backend for the same cores, and every additional subscriber on a Realtime channel multiplies that same check again.
Four things typically go wrong, often together:
- No index on owner_id, so Postgres has no path to a user's rows and runs a sequential scan across the whole table, with the function call layered on top of every row it checks.
- auth.uid() called directly instead of wrapped in a subquery, so Postgres treats the call as something to re-run per row instead of hoisting it into an InitPlan (see above).
- A policy that checks membership through another table (team_id in (select team_id from team_members where user_id = auth.uid())), which runs that subquery, potentially unindexed, once per candidate row.
- Realtime postgres_changes subscriptions, which re-run the same policy once per connected subscriber for every row change, so a policy that's fine for one API request becomes the throughput ceiling for a live feature with many concurrent viewers.
A join-based policy on an unindexed column with an unwrapped auth.uid() call, subscribed to by a Realtime channel, is the exact combination that shows up as a support ticket or a red flag in the Performance Advisor.
The Solution
Fix RLS performance in the order Postgres pays the cost:
Flow diagram: a query hits the RLS policy, which fans out into four per-row costs — no index, an unwrapped auth.uid() call, a cross-table membership check, and per-subscriber Realtime re-evaluation — then collapses back into a single indexed check once indexed, wrapped, and moved into a security definer function
In priority order:
- Index every column an RLS policy filters on.
- Wrap auth.uid() / auth.jwt() in (select ...) so Postgres hoists it into an InitPlan evaluated once per statement instead of once per row.
- Replace correlated cross-table checks with an indexed security definer function, written in the direction that lets the index do the work.
- Scope every policy to authenticated so the anon role doesn't pay for a check it will always fail.
- Confirm your connection pooling mode isn't quietly reintroducing stale claims or extra planning cost.
- Keep any policy on a Realtime-subscribed table especially cheap; its cost multiplies by subscriber count, not row count.
- Verify all of the above with EXPLAIN ANALYZE and the Supabase Performance Advisor. Don't guess.
Prerequisites
- A Supabase project (or self-hosted Postgres 15+) with RLS already enabled on at least one table
- psql, the Supabase SQL Editor, or the Supabase CLI to run migrations
- Basic familiarity with CREATE POLICY and PostgREST
- Enough comfort with EXPLAIN ANALYZE output to tell a Seq Scan from an Index Scan
- pgbench (ships with any Postgres install) if you want to load-test before and after, per Step 7 below
Step-by-Step Implementation
Step 1: Reproduce the slow query with EXPLAIN ANALYZE

Measure before changing anything. Set the session as PostgREST would for a real authenticated request, then run the query:
set role authenticated;set request.jwt.claims to '{"role":"authenticated","sub":"3f1e2a4c-0000-0000-0000-000000000000"}';explain analyzeselect * from documents;
An unindexed, unwrapped policy is unmistakable in the plan:
Seq Scan on documents (cost=0.00..48291.00 rows=1 width=712) (actual time=0.031..171.442 rows=1 loops=1) Filter: (auth.uid() = owner_id) Rows Removed by Filter: 99999Planning Time: 0.112 msExecution Time: 171.489 ms
Seq Scan plus a Filter line containing your policy expression is the signature of an RLS performance problem: Postgres is checking auth.uid() = owner_id against every row instead of seeking directly to the matches.
Step 2: Index the columns your policies filter on
create index idx_documents_owner_id on documents (owner_id);
Re-run the same explain analyze. On a 100K-row table, this alone is typically the difference between a ~170ms sequential scan and a sub-millisecond index lookup, though the function-call cost from Step 3 is usually still riding on top of it.
Step 3: Wrap auth functions so Postgres caches them
drop policy "Users can view their own documents" on documents;create policy "Users can view their own documents"on documentsfor selectto authenticatedusing ( (select auth.uid()) = owner_id );
Parenthesizing auth.uid() as a subquery lets Postgres hoist it into an InitPlan evaluated once per statement instead of once per row (see the internals section above for why). Combined with the index from Step 2, this pattern is what Supabase's own benchmarks show taking a plain auth.uid() = user_id check from ~179ms to ~9ms, and a role check involving a join from ~178,000ms to ~12ms on a large table. See the Supabase RLS performance guide for the full set of benchmarks.
Step 4: Move cross-table checks into a security definer function
Multi-tenant policies that check team membership are the most expensive pattern, because the subquery runs per candidate row and, without care, still has to fight RLS on the membership table itself:
-- Slow: re-checks membership per row; direction forces a scan of team_memberscreate policy "Team members can view team documents"on documentsfor selectto authenticatedusing ( auth.uid() in ( select user_id from team_members where team_members.team_id = documents.team_id ));
Rewrite the subquery in the other direction: filter team_members by the known user first, then wrap it in an indexed security definer function so it runs once, not per row:
create index idx_team_members_user_id on team_members (user_id);create or replace function private.user_team_ids()returns setof uuidlanguage sqlsecurity definerset search_path = ''stableas $$ select team_id from public.team_members where user_id = (select auth.uid())$$;create policy "Team members can view team documents"on documentsfor selectto authenticatedusing ( team_id in (select private.user_team_ids()) );
security definer runs the function with its owner's privileges, so it can read team_members without re-triggering that table's own RLS. set search_path = '' with schema-qualified names closes the privilege-escalation hole an unset search path opens on security-definer functions. The official Supabase RLS guide treats this as mandatory, not optional hardening. Marking the function stable also makes it eligible for the same InitPlan hoisting described above. Rewriting the join direction like this is the same pattern behind Supabase's published benchmark of a team-membership check dropping from ~9,000ms to ~20ms.
Step 5: Scope every policy to a role
Even a simple auth.uid() = owner_id policy still evaluates for an anonymous request before failing it, unless the policy is scoped:
create policy "Users can view their own documents"on documentsfor selectto authenticatedusing ( (select auth.uid()) = owner_id );
Step 6: Verify with the Performance Advisor
In the Supabase dashboard, open Database → Advisors → Performance and re-run the audit. Two lint rules map directly to what you just fixed:
- auth_rls_initplan: an auth function called without the (select ...) wrapper
- multiple_permissive_policies: more than one permissive policy for the same role and action, which Postgres evaluates and ORs together instead of short-circuiting
A clean run of both, plus EXPLAIN ANALYZE showing Index Scan instead of Seq Scan, is the real definition of fixed. A dashboard that merely feels faster is not proof of anything.
Step 7: Load-test the fix, not just a single query
A single EXPLAIN ANALYZE proves the plan changed; it doesn't prove the fix holds under concurrency, where RLS cost is paid on every connection at once. pgbench can drive that with a custom script:
-- rls-check.sql\set owner_id random(1, 100000)select * from documents where owner_id = :owner_id;
pgbench -c 20 -j 4 -T 30 -f rls-check.sql "$DATABASE_URL"
Run this against the database once before Steps 2 through 4 and once after, and compare latency average and tps (transactions per second) in the output. This is the same before/after comparison behind every benchmark number cited in this guide, and it's the only way to know whether a fix that looked good in a single EXPLAIN ANALYZE holds up when 20+ connections are hitting the policy at once.
RLS and Connection Pooling (PgBouncer / Supavisor)
Supabase routes API traffic through Supavisor, its managed fork of PgBouncer-style pooling, in transaction mode by default, and this interacts directly with how RLS claims are set.
PostgREST injects the current user's claims into the session with set_config('request.jwt.claims', '...', true). The third argument, true, means local to the current transaction, and Postgres automatically reverts that value the moment the transaction commits or rolls back. This is what makes transaction-mode pooling safe: even though the underlying TCP connection to Postgres gets handed to a different client's transaction moments later, the claims from the previous transaction are already gone by then, so one user's session can't leak into another's query.
What breaks this guarantee:
- Statement-mode pooling, where the pool can hand out a connection mid-session rather than only between transactions. Avoid it for any workload using request.jwt.claims-based RLS.
- Manually running set request.jwt.claims = ... without local (that is, not through set_config(..., true)) in a custom script or migration tool. A session-level SET persists past the transaction and can leak into whatever runs next on that pooled connection.
- Reusing a raw psql session across multiple simulated users when testing locally. Always open a fresh transaction (or reconnect) per simulated user, matching Step 1's pattern.
Stick to Supabase's default transaction-mode pooling for anything that goes through PostgREST. If you connect directly with psql or a server-side client to test RLS as different users, wrap the claim-setting and query in the same transaction (begin; set local request.jwt.claims = '...'; select ...; commit;) rather than a bare session-level set.
RLS and Supabase Realtime at Scale
postgres_changes subscriptions apply RLS per subscriber, per row change, not once per change overall. A single UPDATE on a table with 500 connected subscribers means Postgres evaluates that row's RLS policy 500 times, and Realtime's change-processing pipeline runs on a single thread to preserve delivery order, so this authorization cost sits directly on the critical path for message throughput. It is not something you can scale away with a bigger database instance.
A policy that's fast for a normal API request (a few milliseconds, indexed, wrapped) can still become the ceiling on how many Realtime messages per second your project can deliver once fan-out is high enough. Everything in the Step-by-Step section above is a prerequisite here, not optional, because there's no additional caching layer between the WAL and the per-subscriber check.
Mitigations specific to Realtime:
- Apply every fix in the Step-by-Step section to any table used with postgres_changes first. An unindexed or unwrapped policy costs far more once it's multiplied by subscriber count.
- For high-fanout use cases (a change visible to hundreds or thousands of simultaneous viewers, like a live leaderboard or a public activity feed), prefer Realtime's Broadcast feature, which you trigger explicitly from a database function or your application, over postgres_changes, which re-derives visibility per subscriber on every change.
- Keep write volume on Realtime-subscribed tables moderate; very high insert/update rates combined with many subscribers compound the same single-threaded bottleneck from both directions at once.
- Watch Supabase's Realtime reports dashboard for message latency and dropped-connection signals under load, rather than inferring throughput headroom from a quiet development environment.
See Supabase's own write-up on Realtime Postgres Changes for the full mechanics of how postgres_changes authorization is applied.
Testing and Monitoring RLS Policies
Fixing a policy once doesn't guarantee the next migration won't reintroduce the same mistake. Two habits make that durable.
Catch regressions with pg_stat_statements. Enable it (create extension if not exists pg_stat_statements;) and periodically check which queries are spending the most total time in production — this catches a slow RLS-affected query even when no single request feels slow enough to notice:
select query, calls, mean_exec_time, total_exec_timefrom pg_stat_statementswhere query ilike '%documents%'order by total_exec_time desclimit 10;
Test policies as part of CI, not only in the SQL editor. A minimal pattern, requiring no extra dependency, runs the same set local request.jwt.claims trick from Step 1 inside a transaction per test case, then asserts on row counts:
begin;-- Simulate user Aset local role authenticated;set local request.jwt.claims to '{"sub":"11111111-1111-1111-1111-111111111111"}';select is( (select count(*) from documents)::int, 2, 'user A sees exactly their 2 documents');rollback;
The is(...) assertion above is pgTAP syntax, if you want a proper test framework wired into make test or CI. A plain select count(*) with an application-level assertion works equally well if you'd rather not add the extension. Either way, the goal is the same: a policy change should fail a test before it reaches production, not surface as a support ticket about a document the wrong user could see, or one the right user suddenly can't.
Common Problems / Errors
RLS policy works but the query is still slow after adding an index
Check whether the policy still calls auth.uid() unwrapped. An index doesn't help if Postgres is still re-evaluating the function per row on top of the scan. Wrap it as shown in Step 3.
"infinite recursion detected in policy" on a table
This happens when a policy on table A queries table B, and table B's own RLS policy queries table A back. Break the cycle with a security definer function that bypasses RLS on the inner lookup, as in Step 4.
Realtime subscriptions or PostgREST requests time out under load, but a manual SQL query is fine
RLS cost multiplies with concurrency, and with Realtime it multiplies again by subscriber count (see the dedicated section above). This points to the underlying policy still needing the Step 1–4 treatment, not a Realtime-specific bug.
A user occasionally sees another user's data right after switching accounts in the same client
This is almost always a stale-session or connection-reuse issue, not an RLS logic bug. Check that claim-setting uses transaction-local scope (set local, or set_config(..., true)) as described in the pooling section, and that your pooler runs in transaction mode, not statement mode.
Two policies for the same action both look correct, but access is broader than expected
Multiple permissive policies are combined with OR: if either one passes, access is granted. If you need an additional hard constraint (for example, "and the account isn't suspended"), express it as a restrictive policy, which combines with AND, instead of folding every condition into one permissive policy.
Best Practices
- Index every column referenced in a USING or WITH CHECK clause. Treat it as part of the schema, not an afterthought.
- Wrap every auth.uid() and auth.jwt() call in (select ...), with no exceptions.
- Explicitly scope every policy to authenticated (or the specific role it's meant for) instead of leaving it open to public.
- Push cross-table checks into indexed, stable security definer functions with set search_path = '', never into a raw correlated subquery inside the policy.
- Re-check EXPLAIN ANALYZE and run the Performance Advisor after every policy change. A plan moving from Seq Scan to Index Scan is the only reliable confirmation.
- Keep one permissive policy per role/action where possible. Multiple permissive policies are OR-combined, and Postgres still evaluates all of them.
- Stay on transaction-mode connection pooling for any table using JWT-claim-based RLS, and treat any table wired into postgres_changes as performance-critical — both costs scale with concurrency and subscriber count, not row count.
Performance and Security Considerations
None of the fixes above are a license to cut corners for speed. Every one of them changes how fast a check runs, never what it allows, and an unqualified search_path on a security definer function is a real privilege-escalation vector, not a style nit.
RLS cost scales with concurrency, not just row count: a 9ms policy at low traffic can still saturate a database at hundreds of requests per second, so load-test with realistic concurrency (Step 7) instead of trusting a single EXPLAIN ANALYZE. Realtime compounds this further, since a policy tuned only against API traffic can still become the throughput ceiling on a heavily-watched channel (see the dedicated section above).
Connection pooling adds a correctness risk on top of the performance one — statement-mode pooling or a session-level (non-local) claim can leak one user's session context into another's query, which is a data-isolation bug, not just a slowdown. service_role bypasses RLS entirely, so background jobs and admin tooling using that key skip these policies altogether; don't write policies "for" service_role, and don't rely on RLS to protect data from code that already holds that key. None of this replaces input validation, either: WITH CHECK stops a user from writing a row they shouldn't own, but it doesn't check the shape or content of that row.
Alternatives / Comparison
RLS vs. Application-Layer Authorization
| Aspect | Postgres RLS | Application-Layer Checks |
|---|---|---|
| Enforced by | The database, on every query path | Application code, per endpoint |
| Bypass risk | Low — applies even to ad hoc SQL/PostgREST | Higher — one missed WHERE clause can leak data |
| Performance tuning | Indexes + query-planner literacy | Standard application profiling |
| Realtime fan-out cost | Multiplies per subscriber (postgres_changes) | Controlled explicitly by your broadcast/fan-out code |
| Best fit | Multi-tenant apps, direct client-to-DB access | Business rules too complex for a row filter |
Most production Supabase apps use both: RLS as the non-negotiable data-layer guarantee, and application code for logic that doesn't reduce cleanly to a boolean row filter. Our backend development services team designs this split as part of any multi-tenant PostgreSQL database architecture engagement.
When Should You Use It?
Lean on RLS-based authorization when:
- Clients (browser, mobile app) talk to Postgres directly through Supabase's PostgREST API or client libraries
- The authorization rule reduces to a per-row boolean check (ownership, team membership, tenant ID)
- You want a guarantee that holds even if application code has a bug
Reach for application-layer checks instead when:
- The decision depends on complex, multi-step business logic that doesn't reduce to a row filter
- All database access already goes through a trusted backend service using the service_role key
- The policy would require expensive real-time computation that's cheaper to cache at the application layer
- You need very high-fanout live updates (thousands of simultaneous viewers). Realtime Broadcast, driven from your own code, sidesteps the per-subscriber RLS multiplication entirely
They aren't mutually exclusive. The common production pattern uses RLS as the floor, with application logic layered on top for anything more nuanced. If you're planning a multi-tenant SaaS architecture from scratch, decide this split before writing your first policy, not after the first slow query.
FAQ
Why is my Supabase query slow only after enabling RLS?
RLS adds the policy's USING expression as a filter to every row Postgres scans. If the filtered column isn't indexed, or the policy calls auth.uid() without wrapping it in (select ...), Postgres pays that cost per row instead of once per query.
Does wrapping auth.uid() in a SELECT change what the policy allows?
No. (select auth.uid()) returns the exact same value as auth.uid(). The wrapper only changes how Postgres plans and caches the call (see the internals section above), not the security logic.
Is RLS too slow for large-scale Supabase applications?
No. Correctly indexed, correctly wrapped RLS policies scale to millions of rows with single-digit-millisecond overhead. The performance problems people hit are almost always one of the patterns in this guide, not a fundamental limit of RLS itself.
Should I disable RLS for performance and handle authorization in my API instead?
Only if every database access path already goes through that trusted API using the service_role key. If any client (browser, mobile app, PostgREST, Realtime) talks to Postgres directly, disabling RLS removes your only enforcement layer.
How do I find which policies are causing performance problems?
Run Supabase's Performance Advisor (Dashboard → Database → Advisors → Performance). It names auth_rls_initplan and multiple_permissive_policies directly. For anything it doesn't catch, EXPLAIN ANALYZE with a real JWT claim set (Step 1) will show it as a Seq Scan with your policy in the Filter line, and pg_stat_statements will surface it in production over time.
Why is my Realtime subscription slow even though the same query is fast through the API?
postgres_changes re-evaluates RLS once per connected subscriber for every row change, and Realtime processes changes on a single thread to preserve order. A policy that's fast for one API request can still be the throughput ceiling once hundreds of subscribers are watching the same table. Apply the same indexing and wrapping fixes, and consider Broadcast for very high fan-out.
Conclusion
A handful of specific, well-documented patterns cause almost every Supabase RLS performance problem: unindexed policy columns, unwrapped auth.uid() calls, correlated cross-table checks, and, for live features, per-subscriber Realtime multiplication. Each one forces Postgres to redo work it only needs to do once. Indexing the right columns, wrapping auth functions in (select ...) so Postgres can hoist them into an InitPlan, and moving cross-table logic into indexed security definer functions typically take affected queries from seconds back to single-digit milliseconds, without loosening what any policy allows. Run EXPLAIN ANALYZE, a pgbench concurrency test, and the Performance Advisor on every policy before shipping it — the query plan is the evidence, not a subjective sense that the dashboard loaded faster.
Need Help Scaling a Supabase or Postgres Application?
If your Supabase project is hitting RLS performance limits, Realtime fan-out is capping your throughput, or you're designing multi-tenant authorization from scratch, our backend development team can review your schema, policies, and query plans and fix what's costing you. Contact us to discuss your project.
Further Reading
- Supabase Realtime: Building Real-Time Applications with PostgreSQL Changes
- Supabase Edge Functions: When to Use Serverless Functions vs Your Backend
- Supabase in Production: Performance, Security, Backups, and Scaling

Aarav Sharma
Lead Software Engineer
Aarav leads product engineering at Matlab Infotech, where he has shipped mobile and web platforms across healthcare, fintech, and SaaS. He writes about pragmatic engineering and shipping fast without cutting corners.


