RowShield
Guides

The bill spiked and the only change was policies

The invoice climbed and the traffic did not. The only deploy that mattered was a batch of row-level security policies, which should have been a rounding error; instead compute rose and stayed risen. Two policy habits explain nearly every case shaped like this: a predicate filtering on a column with no index, and auth.uid() called bare, once per candidate row, inside policies on busy tables.

Neither habit is a security flaw — the policies grant exactly what was intended, expensively. Both are mechanical to find and cheap to fix, which this page does in order: what each habit costs, the queries that identify them in your schema, and the rewrites. Then, how both get watched so the next policy batch does not quietly rent compute forever.

Rules that check this

The two habits

Every query against a table with RLS pays the predicate. When the filtered column has no index, payment is a sequential scan: read every row, test each one, discard most. Metered billing translates the arithmetic directly — work multiplied across every query and every row becomes a line item, and because the cause is configuration rather than traffic, the growth continues long after the traffic peak passes.

A bare auth.uid() inside a policy is re-evaluated for every candidate row, because Postgres cannot assume the function is stable across the statement. Wrapping it as (SELECT auth.uid()) changes nothing semantically and lets the planner hoist it into an InitPlan evaluated once per statement. On large tables, wherever that measured pattern holds, the difference is routinely ten to a hundred times.

The habits compound. A per-row function call sitting inside an unindexed filter is the worst version of each: the scan visits every row, and the function runs at every visit. Fixing either helps; fixing both returns the table to something close to its pre-policy cost, and the plan output proves it.

Find both in your schema

One catalog query surfaces the suspects — every policy whose conditions mention auth.uid(), listed beside the table and command it taxes, ready to cross-check against the index list that follows. Anything listed is only a candidate; whether it costs real money is decided by the indexes:

SELECT tablename, policyname, cmd, qual, with_check
FROM pg_catalog.pg_policies
WHERE schemaname = 'public'
  AND (qual ILIKE '%auth.uid()%' OR with_check ILIKE '%auth.uid()%')
ORDER BY tablename;

Cross-check the indexes

Cross-reference each taxed table against its indexes — pg_indexes renders them readably — and mark any predicate column lacking a covering index. The expensive pairs are function-plus-missing-index on the same busy table; that concentration is where bills grow, and where the rewrites below pay for themselves fastest:

SELECT tablename, indexname, indexdef
FROM pg_catalog.pg_indexes
WHERE schemaname = 'public'
ORDER BY tablename, indexname;

The rewrites

Two rewrites cover both habits. Wrap every bare call, which changes no semantics and lifts evaluation out of the per-row loop, and give each filtered column an index, built concurrently so the table keeps serving while it fills. The changes are independent and safe to apply separately, though their savings compound when they land together:

-- Before: evaluated once per row
USING (auth.uid() = user_id)

-- After: evaluated once per statement
USING ((SELECT auth.uid()) = user_id)

CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_projects_owner_id
  ON public.projects (owner_id);

So the next batch behaves

Re-measure with the same EXPLAIN ANALYZE you ran before: the plan should show the InitPlan evaluated once and index scans where sequential scans stood. Per-query compute drops accordingly, and the invoice resumes tracking traffic instead of table size multiplied by query count.

RowShield evaluates both habits as rules on every scan — UNINDEXED_RLS_PREDICATE and RLS_UNWRAPPED_AUTH_CALL, each reported at medium severity, deduplicated per table and column so one index or one wrap clears the finding rather than spawning a list. Scans diff between runs, and alerts fire on transitions: created when a new policy lands carrying either habit, regressed if a fix reverses.

RowShield is an independent product, unaffiliated with and not endorsed by Supabase; it reads pg_catalog metadata only, never rows, and Supabase and Postgres appear as descriptive references. Run a free audit — no account — at rowshield.dev/audit to see which of your policies carry either habit before the next invoice does.

Frequently asked

Is this a security issue?
No. Both habits waste computation on policies that grant exactly what they should, which places them at medium severity. The bill and the latency are the symptoms; exposure is not part of this picture.
Why is wrapping auth.uid() safe?
Because the subquery form is semantically identical — the planner simply recognises it can evaluate the value once instead of per row. It is the documented pattern for policy performance on busy tables.
Do composite indexes help the predicate?
A column anywhere in a valid index key covers the predicate, though a leading position serves best. Demanding a dedicated leading index everywhere produces more noise than the marginal gain justifies.

Check your project in about ten seconds

Paste a URL. No signup, no writes, nothing stored.

Run the free audit
supabase bill spike rlssupabase compute cost policiesbare auth.uid() performanceunindexed rls predicate cost