Supabase Row Level Security Policy Performance Degradation Fix

Enabling RLS on a Supabase table and watching your query latency jump 3-8x isn’t a fringe experience — it’s documented behavior that hits small teams hardest because they’re usually the last to run proper benchmarks before shipping.
Key Takeaways
- RLS policy performance degrades most severely when policies reference
auth.uid()on every row scan without a matching index, triggering full table evaluations instead of index seeks.- Community benchmarks (documented on Supabase GitHub Discussions and Discord, early 2026) show query times rising from ~2ms to 15-40ms on tables exceeding 50,000 rows when naive RLS policies are applied.
- Small teams are disproportionately affected because they prototype with RLS enabled but skip the query plan analysis step that would catch degradation before production.
- Wrapping
auth.uid()in asecurity definerfunction and adding abtreeindex on foreign key columns eliminates most of the performance gap — confirmed in Supabase’s own optimization documentation.- Teams running fewer than five engineers rarely have a dedicated DBA to audit
EXPLAIN ANALYZEoutput, making automated policy auditing tools increasingly important in 2026.
The Problem Isn’t RLS. It’s How Policies Are Written.
Row Level Security is a PostgreSQL feature, not a Supabase invention. Supabase makes it easy to enable — genuinely useful. But “easy to enable” and “easy to configure well” are two different things.
RLS works by appending a WHERE clause to every query on a secured table. That clause evaluates your policy expression for each candidate row. When the expression calls auth.uid() — how Supabase identifies the current user — PostgreSQL resolves that function call and compares it against a column value, row by row.
The critical wrinkle: auth.uid() is a STABLE function in Supabase’s implementation, meaning PostgreSQL can cache its result within a single query. But whether it actually does depends on the query planner’s decisions, which shift based on table statistics, estimated row counts, and whether a usable index exists on the column being compared.
No index on user_id? The planner walks the whole table. That’s where the performance degradation starts, and it compounds as row count grows.
Why Small Teams Hit This First
Small teams typically follow the same pattern. They spin up a Supabase project, read the “enable RLS” documentation, add a policy like:
CREATE POLICY "Users can see own rows"
ON profiles
FOR SELECT
USING (user_id = auth.uid());
No index on user_id. No EXPLAIN ANALYZE. They test with 100 rows in development, everything’s fast, they ship.
Three months later the table has 80,000 rows. Queries that returned in 2ms are now taking 35ms. The team posts on Discord. The answer is always the same: add the index, check your policies.
Supabase’s own documentation (updated early 2026) now explicitly warns about this, recommending developers run EXPLAIN ANALYZE on queries touching RLS-protected tables. But the warning lives in the optimization section — not on the page where you first enable RLS. That placement gap is where teams get caught.
By mid-2026, Supabase reported over one million databases on their platform (per their public dashboard stats). A significant fraction are small-team projects that enabled RLS early and never revisited it.
Where the Degradation Actually Happens
The auth.uid() Caching Problem
PostgreSQL marks auth.uid() as STABLE — it returns the same value within a single query, so it should be evaluated once. Often it is. But when the query planner sees a low-selectivity estimate on the filtered column (common with outdated table statistics), it may choose a sequential scan regardless of index availability. The RLS WHERE clause then runs against every row in that scan.
Benchmarks shared on Supabase GitHub Discussions thread #18803 (February 2026) put real numbers on this: a table with 100,000 rows, no index on user_id, RLS enabled. A SELECT * on that setup produced query plans with roughly 95,000 row evaluations and execution times of 28-42ms on a shared Supabase Pro instance. Adding a standard btree index on user_id dropped that to 1.2-1.8ms — a 20-30x improvement.
The Fix Most Teams Skip
The immediate fix is straightforward:
CREATE INDEX idx_profiles_user_id ON profiles(user_id);
But it’s not just about adding any index. The column in your policy expression — whatever you’re comparing against auth.uid() — needs an index. If your policy uses a join or subquery to resolve ownership (say, checking a teams table to verify a user belongs to a team that owns a resource), each step in that chain needs its own index.
Supabase documentation also recommends wrapping auth.uid() in a security definer function to force stable evaluation:
CREATE OR REPLACE FUNCTION requesting_user_id()
RETURNS uuid
LANGUAGE sql STABLE
AS $$
SELECT auth.uid()
$$;
This gives the planner a stronger guarantee about the function’s stability, which can meaningfully change plan selection.
Policy Complexity and Join Depth
Simple ownership checks (user_id = auth.uid()) are cheapest. The degradation compounds with policy complexity.
| Policy Type | Avg Query Time (100k rows) | Index Required | Relative Cost |
|---|---|---|---|
| Direct column match | 1.5ms (indexed) / 35ms (unindexed) | Yes, on matched column | Low |
| Single-join ownership | 4-8ms (indexed) / 80ms+ (unindexed) | Yes, on FK and join column | Medium |
| Subquery with EXISTS | 8-15ms (indexed) / 150ms+ (unindexed) | Yes, composite recommended | High |
| Multi-table join chain | 15-30ms (indexed) / 300ms+ (unindexed) | Yes, all FK columns | Very High |
Estimates based on Supabase community benchmarks and SupaExplorer documentation, 2026. Pro-tier shared instances.
The jump from a direct match to a subquery with EXISTS is where small teams building permission models — user → team member → team → resource — start seeing latency that actually affects UX.
When This Approach Doesn’t Scale
RLS isn’t the right tool for every permission model. Teams building complex multi-tenant SaaS with deep role hierarchies will find that even well-indexed policies hit a ceiling. Anything beyond a two-table join in a policy degrades consistently at scale, per community benchmark data. In those cases, RLS works well as a security enforcement layer, but pre-computing and caching permission checks at the application layer becomes necessary for read-heavy paths.
This isn’t a knock on RLS. It’s a scoping issue. The feature is excellent at what it does — row-level enforcement without application-layer bypasses. Asking it to also replace a permissions engine is a different request entirely.
What Small Teams Should Actually Do
Greenfield project, RLS just enabled. Run EXPLAIN ANALYZE before any user-facing feature ships. Not after. The query plan at 500 rows looks different than at 500,000 rows, but the index gap shows up immediately in plan output. If you see Seq Scan on your RLS-protected table, that’s the signal. Add the index now.
Existing app with degraded performance. First, run ANALYZE your_table_name; to refresh table statistics — sometimes the planner just needs updated row counts. Then add indexes. Then audit policy complexity. If you’ve got EXISTS subqueries in your policies, consider materializing ownership data into a denormalized column to reduce join depth.
Multi-tenant SaaS with complex permissions. Don’t rely solely on RLS for deep permission trees. Use it as a security layer — it’s strong there — but offload complex read-heavy permission logic to the application tier where you can cache results efficiently.
The Configuration Gap Is the Problem
RLS works. The performance degradation people hit isn’t a framework bug — it’s a predictable, measurable, fixable configuration gap.
The data is unambiguous:
- Unindexed policies on tables over 50k rows produce 20-30x slower queries
- Complex join-based policies multiply that cost further
security definerwrapper functions andbtreeindexes close most of the gap- Small teams without dedicated database engineers are most likely to miss this until it’s already a production problem
Near-term outlook: Expect Supabase to add policy performance warnings directly in the Dashboard SQL editor — they’ve been moving in that direction based on public roadmap updates. Their 2026 Q3 roadmap hints at automatic policy analysis tooling. If that ships, it changes the small-team story significantly, catching degradation before deployment rather than after.
The action is simple. Before your next Supabase deploy, run EXPLAIN ANALYZE on every query that touches an RLS-protected table. What does your plan output actually say?
References
- Row Level Security | Supabase Docs
- Optimize RLS Policies for Performance - Postgres Best Practice | SupaExplorer
Photo by Houston SEO Directory on Unsplash


