Security Barrier Views and Leakproof Functions #
A tenant-scoped search — WHERE lower(email) = lower($1) — uses an expression index and returns in a millisecond when a DBA runs it. Through the application, connected as a role subject to row-level security, the same query scans every row the tenant can see and takes two seconds. The index exists, the statistics are fine, and the plan still refuses to use it.
Normally the planner pushes predicates as close to the scan as possible, as described in filter pushdown mechanics. Security barriers are the deliberate exception: pushing a predicate down can leak data, so the planner declines unless the predicate is provably safe.
The Planner Condition #
Two features create a security boundary between a user’s predicates and the rows they filter:
security_barrierviews —CREATE VIEW … WITH (security_barrier). The view’s ownWHEREclause is a security condition.- Row-level security policies —
CREATE POLICY … USING (…)on a table with RLS enabled. Policy expressions are security conditions.
The risk is simple: if a user’s predicate runs before the security condition, a function in it could observe rows the user must not see — by raising an error containing a value, logging, or timing. So the planner orders evaluation by security level and applies a rule:
A user predicate may be evaluated ahead of a security condition — and used as an index condition — only if every function and operator in it is LEAKPROOF.
Leakproof status lives in pg_proc.proleakproof. Many built-in comparison operators are leakproof: integer and text equality, most numeric and timestamp comparisons. Many common functions are not: lower(), upper(), LIKE and regular-expression matches, casts that can raise errors, and any user-defined function unless a superuser marks it.
In plan terms, a non-leakproof predicate cannot become an Index Cond; it is applied as a Filter after the security conditions, above a scan that could only use the policy’s own indexable conditions.
Annotated EXPLAIN Evidence #
Setup:
ALTER TABLE contacts ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON contacts
USING (tenant_id = current_setting('app.tenant_id')::bigint);
CREATE INDEX contacts_lower_email_idx ON contacts (lower(email));
As a superuser (policies bypassed):
Index Scan using contacts_lower_email_idx on contacts (actual time=0.03..0.04 rows=1 loops=1)
Index Cond: (lower(email) = '[email protected]'::text)
Execution Time: 0.07 ms
As the application role:
Index Scan using contacts_tenant_id_idx on contacts (actual time=0.05..1904.2 rows=1 loops=1)
Index Cond: (tenant_id = (current_setting('app.tenant_id'))::bigint) -- the policy
Filter: (lower(email) = '[email protected]'::text) -- user predicate held back
Rows Removed by Filter: 1840211
Execution Time: 1904.6 ms
-- the lower() index is never considered: lower() is not leakproof
Confirm the leakproof status:
SELECT proname, proleakproof FROM pg_proc WHERE proname IN ('lower', 'texteq', 'textlike');
proname | proleakproof
----------+--------------
lower | f
texteq | t
textlike | f
Step-by-Step Resolution #
-
Capture the plan as the application role. Use
SET ROLE app_userand set any session variables the policy reads. Superusers,BYPASSRLSroles and table owners withoutFORCE ROW LEVEL SECURITYdo not see policies. -
Find user predicates below
Filter:alongside a policyIndex Cond. A largeRows Removed by Filteron a query that should be selective is the signal — the interpretation is in understanding Filter vs Recheck conditions. -
Check
proleakprooffor every function and operator in the held-back predicate. -
Rewrite with leakproof operators. Store the normalised value and compare with plain equality:
ALTER TABLE contacts ADD COLUMN email_normalized text GENERATED ALWAYS AS (lower(email)) STORED; CREATE INDEX CONCURRENTLY contacts_tenant_email_norm_idx ON contacts (tenant_id, email_normalized); -- query: WHERE email_normalized = lower($1) -- lower($1) is evaluated on the constant, not on table rows, so nothing leaks -
Or put the predicate inside the security boundary. For a
security_barrierview, a parameterised set-returning function owned by a trusted role can apply both conditions internally. Marking a functionLEAKPROOFis a superuser decision that must be justified by reading its code; do not mark functions that can raise data-dependent errors. -
Verify that the user predicate now appears in
Index Cond:Index Scan using contacts_tenant_email_norm_idx on contacts (actual time=0.04..0.05 rows=1 loops=1) Index Cond: ((tenant_id = (current_setting('app.tenant_id'))::bigint) AND (email_normalized = '[email protected]'::text)) Execution Time: 0.09 ms
Before and After #
-- BEFORE: app role, lower(email) predicate under RLS
Index Cond: (tenant_id = …) Filter: (lower(email) = …) Rows Removed by Filter: 1840211 Execution Time: 1904.6 ms
-- AFTER: stored normalized column, leakproof equality
Index Cond: ((tenant_id = …) AND (email_normalized = …)) Execution Time: 0.09 ms
Common Pitfalls #
Diagnosing as a superuser. The plan you see has no policies. Diagnostic signal: fast plans in psql that do not match auto_explain output. Fix: SET ROLE to the application role.
Marking functions LEAKPROOF to make plans faster. A function that raises an error such as “invalid input: LEAKPROOF for functions audited to raise no data-dependent errors.
LIKE searches under RLS. textlike is not leakproof, so a prefix search cannot use a pattern index ahead of the policy. Diagnostic signal: Filter: (name ~~ 'abc%') over all tenant rows. Fix: include the tenant column in the index so the policy’s own index condition narrows the scan, or search a normalized column with range equality.
Views without security_barrier used for isolation. A plain view lets predicates be pushed into it, and a crafted function can see hidden rows. Diagnostic signal: tenant isolation implemented with an ordinary view. Fix: use RLS or WITH (security_barrier); the planning cost above is the price of that protection.
Frequently Asked Questions #
What does LEAKPROOF mean for a function?
It reveals nothing about its arguments except through the return value: no data-dependent errors and no side effects. The planner may evaluate it before security conditions. Only superusers can mark a function LEAKPROOF.
Does row-level security disable index usage? Not in general. Policy conditions can use indexes, and user predicates built from leakproof operators can too. Predicates calling non-leakproof functions cannot be index conditions ahead of the policy.
Why does the planner treat a superuser’s query differently?
Superusers and BYPASSRLS roles skip policies, and table owners do too unless FORCE ROW LEVEL SECURITY is set, so their plans contain no security conditions.
Related #
- Filter Pushdown Mechanics — parent guide: where predicates are normally placed
- Pushing Predicates into Subqueries — sibling: pushdown rules without a security boundary
- Partial Index Predicate Matching Rules — another place where predicate shape decides index eligibility