Why your Supabase bill spikes after adding RLS
RLS predicates run against every candidate row. Two mistakes make that catastrophic, and both look completely correct when you read the policy.
Rules that check this
- mediumRLS predicate column is not indexed
UNINDEXED_RLS_PREDICATE - mediumauth.uid() not wrapped in a subquery
RLS_UNWRAPPED_AUTH_CALL
The predicate column has no index
A policy filtering on `user_id` with no index on `user_id` degrades every query into a sequential scan of the whole table. It is invisible at a thousand rows and ruinous at a million.
RowShield reports one finding per table and column rather than per policy — three policies filtering the same column need one index between them, and three alerts would be three times the noise for one fix.
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_invoices_user_id ON public.invoices (user_id);
auth.uid() is called per row
A bare `auth.uid()` inside a policy is re-evaluated for every candidate row. Wrapping it in a scalar subquery lets the planner hoist it into an InitPlan evaluated once per statement.
The semantics are identical. On tables past a few thousand rows the difference is routinely ten to a hundred times.
-- Before: evaluated per row USING (auth.uid() = user_id) -- After: evaluated once per statement USING ((SELECT auth.uid()) = user_id)
Correlated subqueries count too
A call inside `EXISTS (SELECT 1 FROM members WHERE user_id = auth.uid())` is still per-row, because that subquery is correlated. RowShield flags it, and the fix is the same wrap.
Measuring the damage honestly
The cost shows up as compute time rather than errors, so measure it before believing it. On any table past a few thousand rows, run your app's hottest query twice — once as the policy currently reads it, once with the predicate wrapped or the index added — and compare execution plans. The unoptimised form shows the policy predicate evaluated per row; the optimised form hoists it into a one-time computation.
Latency is the visible symptom; billing is the invisible one. Supabase charges for compute hours, and per-row evaluation multiplies work under load in ways that track traffic growth exactly. Teams routinely discover the pattern when a bill doubles while user growth did not.
Treat both findings as paired work: adding the index without wrapping auth.uid() leaves half the multiplier in place, and vice versa. RowShield reports each independently because either alone justifies the fix — and because on scheduled scans, a regression in either is worth knowing about before the invoice arrives rather than after.
Choosing the index itself deserves a moment of thought. The predicate column wants a single-column b-tree unless your queries consistently filter on a fixed combination, in which case a composite with the filter column leading serves better. Partial indexes help when policies exclude a small slice — soft-deleted rows being the classic case. Whatever you choose, create it CONCURRENTLY on live tables to avoid write locks, and re-check that the planner actually uses it: a policy predicate can defeat an otherwise perfect index when types or wrappers differ subtly. Re-run the plan comparison after each change so the improvement is measured rather than assumed - the same discipline this page applies to the original problem. Budget a re-check after major version upgrades too, since planner behaviour around InitPlans shifts across releases and the wrap that won last quarter deserves re-verification.
Frequently asked
- Why CONCURRENTLY?
- It avoids taking a write lock on a live table. It cannot run inside a transaction block, so run that statement on its own.
- Does a composite index count?
- RowShield treats a column as covered if it appears in any valid index key. A leading position serves the predicate best, but demanding one produces more noise than it prevents.
- Is this a security issue?
- No, it is a cost and latency issue, which is why it is reported as medium rather than critical. It is the most common reason a Supabase bill grows faster than traffic.
Check your project in about ten seconds
Paste a URL. No signup, no writes, nothing stored.
Run the free audit