Back to KB
Difficulty
Intermediate
Read Time
7 min

The auth_rls_initplan linter has a blind spot: SECURITY DEFINER bodies

By Codcompass TeamΒ·Β·7 min read

Optimizing Row-Level Security: Bypassing Per-Row Auth Evaluation in PostgreSQL

Current Situation Analysis

Row-Level Security (RLS) is the standard mechanism for enforcing data isolation in multi-tenant PostgreSQL deployments. When paired with frameworks like Supabase or PostgREST, developers typically rely on session context functions such as auth.uid() to dynamically filter rows based on the authenticated user. The pattern works seamlessly during development, but it introduces a critical performance degradation as table cardinality grows.

The root cause lies in how PostgreSQL's query planner evaluates policy expressions. When a policy contains a bare function call like auth.uid(), the executor treats it as a volatile expression tied to each row scan. On a table with one million records, this forces the database to invoke the context lookup one million times. The result is a predictable latency spike: queries that execute in 30–50 milliseconds during prototyping routinely balloon to 15–30 seconds in production.

The industry standard mitigation is wrapping the context call in a scalar subselect: (SELECT auth.uid()). This syntax signals the planner to evaluate the expression once, cache the result, and reuse it across the entire query execution. The optimization is well-documented, and automated linters like auth_rls_initplan have been adopted to enforce it during code reviews.

However, this approach contains a structural blind spot. Linters parse policy definitions at the surface level. They validate the USING or WITH CHECK clauses but do not traverse into referenced stored procedures. When developers encapsulate authorization logic inside SECURITY DEFINER functions for reusability or security boundary control, the linter reports a clean pass. The policy technically uses the wrapped pattern, but the actual authentication lookup remains unwrapped inside the function body. The planner never triggers the InitPlan optimization, and the per-row evaluation penalty silently returns.

This gap is frequently overlooked because:

  1. Abstraction hides execution context: Developers assume that wrapping at the policy level propagates the optimization downward.
  2. Linter limitations: Static analysis tools rarely perform recursive AST traversal into function bodies, especially when SECURITY DEFINER changes the security context.
  3. Testing bias: Performance testing on small datasets masks the linear scaling of per-row function calls. The issue only surfaces under production load or during migration to larger schemas.

WOW Moment: Key Findings

The performance delta between a properly optimized RLS policy and a function-obscured one is not marginal; it is architectural. The following comparison isolates the execution behavior across four common implementation patterns.

ApproachEvaluation FrequencyPlanner BehaviorTypical Latency (1M rows)Linter Detection
Bare call in policyPer rowSubPlan / repeated execution15–30sFlagged
Wrapped call in policyOnce per queryInitPlan / constant cache30–50msFlagged (passes)
Bare call in SECURITY DEFINER functionPer rowSubPlan inside function body15–30sMissed (false green)
Wrapped call inside function bodyOnce per queryInitPlan inside funct

πŸŽ‰ Mid-Year Sale β€” Unlock Full Article

Base plan from just $4.99/mo or $49/yr

Sign in to read the full article and unlock all 635+ tutorials.

Sign In / Register β€” Start Free Trial

7-day free trial Β· Cancel anytime Β· 30-day money-back