You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
description: 'An engineering perspective to evaluate Postgres row-level-security footguns'
8
8
---
9
+
9
10
Postgres's Row-Level Security (RLS) is a powerful feature for implementing fine-grained access control, but it's riddled with subtle traps that can destroy performance or completely bypass security. This comprehensive guide covers all the major footguns with practical fixes and real-world examples.
10
11
11
12
---
@@ -19,6 +20,7 @@ Postgres's Row-Level Security (RLS) is a powerful feature for implementing fine-
19
20
**Why it happens:** Postgres must apply RLS filtering first, then non-LEAKPROOF functions, preventing the query planner from using indexes.
20
21
21
22
**Example problem:**
23
+
22
24
```sql
23
25
-- This will cause full table scans even with an index on title
24
26
CREATE POLICY user_documents ON documents
@@ -27,12 +29,13 @@ USING (owner_id = current_user_id() AND title ILIKE '%search%');
27
29
```
28
30
29
31
**The fix:**
32
+
30
33
```sql
31
34
-- Use LEAKPROOF functions or move complex logic out of policies
32
-
CREATE OR REPLACEFUNCTIONsafe_ilike(text, text)
33
-
RETURNS boolean
34
-
LANGUAGE sql
35
-
LEAKPROOF
35
+
CREATE OR REPLACEFUNCTIONsafe_ilike(text, text)
36
+
RETURNS boolean
37
+
LANGUAGE sql
38
+
LEAKPROOF
36
39
AS $$ SELECT $1 ILIKE $2 $$;
37
40
38
41
CREATE POLICY user_documents ON documents
@@ -47,19 +50,21 @@ USING (owner_id = current_user_id() AND safe_ilike(title, '%search%'));
47
50
**The footgun:** Complex RLS policies with subqueries execute for every row, multiplying query cost exponentially.
48
51
49
52
**Bad example:**
53
+
50
54
```sql
51
55
CREATE POLICY complex_access ON orders
52
56
USING (
53
57
EXISTS (
54
-
SELECT1FROM user_permissions up
55
-
JOIN departments d ONup.dept_id=d.id
56
-
WHEREup.user_id= current_user_id()
58
+
SELECT1FROM user_permissions up
59
+
JOIN departments d ONup.dept_id=d.id
60
+
WHEREup.user_id= current_user_id()
57
61
ANDd.region=orders.region
58
62
)
59
63
);
60
64
```
61
65
62
66
**Better approach:**
67
+
63
68
```sql
64
69
-- Move complexity to a LEAKPROOF function
65
70
CREATE OR REPLACEFUNCTIONuser_has_region_access(region_name text)
@@ -69,9 +74,9 @@ LEAKPROOF
69
74
STABLE
70
75
AS $$
71
76
SELECT EXISTS (
72
-
SELECT1FROM user_permissions up
73
-
JOIN departments d ONup.dept_id=d.id
74
-
WHEREup.user_id= current_user_id()
77
+
SELECT1FROM user_permissions up
78
+
JOIN departments d ONup.dept_id=d.id
79
+
WHEREup.user_id= current_user_id()
75
80
ANDd.region= region_name
76
81
);
77
82
$$;
@@ -85,6 +90,7 @@ USING (user_has_region_access(region));
85
90
**The footgun:** Forgetting to index columns used in RLS policies forces sequential scans.
86
91
87
92
**Essential indexes:**
93
+
88
94
```sql
89
95
-- Always index columns used in policies
90
96
CREATEINDEXON orders(tenant_id);
@@ -103,6 +109,7 @@ CREATE INDEX ON orders(tenant_id, owner_id); -- composite for AND conditions
103
109
**Why it's dangerous:** Testing with superuser accounts makes RLS appear to work when it's actually being ignored.
**Attack scenario:** In a multi-tenant medical database, an attacker measures query times to determine if patients with specific conditions exist in other tenants' data.
- Attackers can infer secret cardinality information
172
183
- Works even across network latency in cloud environments
173
184
174
185
**Example vulnerable query:**
186
+
175
187
```sql
176
188
-- Timing reveals if forbidden patients exist
177
-
SELECTCOUNT(*) FROM patients
178
-
WHERE condition ='rare_disease'
189
+
SELECTCOUNT(*) FROM patients
190
+
WHERE condition ='rare_disease'
179
191
AND tenant_id = current_setting('app.tenant_id')::uuid;
180
192
```
181
193
182
194
**Mitigation strategies:**
195
+
183
196
1. Use data-oblivious query patterns (performance cost)
184
197
1. Add artificial delays to normalize timing
185
198
1. Limit query complexity for untrusted users
@@ -189,9 +202,10 @@ AND tenant_id = current_setting('app.tenant_id')::uuid;
189
202
190
203
### 7. CVE-2019-10130: Statistics Leakage
191
204
192
-
**The footgun:** Postgres's query planner statistics could leak sampled data from RLS-protected rows.
205
+
**The footgun:**[CVE-2019-10130](https://www.postgresql.org/support/security/CVE-2019-10130/). Postgres's query planner statistics could leak sampled data from RLS-protected rows.
193
206
194
207
**Technical details:**
208
+
195
209
- Query planner collects statistics by sampling column data
196
210
- Users could craft operators to read statistics containing forbidden data
197
211
- Affected Postgres 9.5-11 before May 2019 patches
@@ -209,12 +223,14 @@ AND tenant_id = current_setting('app.tenant_id')::uuid;
209
223
**The footgun:** Enabling RLS without FORCE allows table owners to bypass policies.
210
224
211
225
**Problem:**
226
+
212
227
```sql
213
228
-- Table owner still sees everything!
214
229
ALTERTABLE orders ENABLE ROW LEVEL SECURITY;
215
230
```
216
231
217
232
**Solution:**
233
+
218
234
```sql
219
235
-- Force RLS for everyone, including owners
220
236
ALTERTABLE orders ENABLE ROW LEVEL SECURITY;
@@ -226,6 +242,7 @@ ALTER TABLE orders FORCE ROW LEVEL SECURITY;
226
242
**The footgun:** USING filters existing rows for SELECT/UPDATE/DELETE, but WITH CHECK validates new/modified rows for INSERT/UPDATE.
227
243
228
244
**Dangerous example:**
245
+
229
246
```sql
230
247
-- Users can INSERT data they can't see!
231
248
CREATE POLICY tenant_data ON orders
@@ -234,6 +251,7 @@ USING (tenant_id = current_setting('app.tenant_id')::uuid);
234
251
```
235
252
236
253
**Correct approach:**
254
+
237
255
```sql
238
256
CREATE POLICY tenant_isolation ON orders
239
257
FOR ALL
@@ -246,13 +264,15 @@ WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);
246
264
**The footgun:** With connection pooling, `current_user` is a shared database role, useless for tenant isolation.
247
265
248
266
**Problem:**
267
+
249
268
```sql
250
269
-- Useless with PgBouncer - all connections share same user
251
270
CREATE POLICY user_data ON orders
252
271
USING (owner_id =current_user);
253
272
```
254
273
255
274
**Solution:**
275
+
256
276
```sql
257
277
-- Use application-controlled session variables
258
278
-- App sets per transaction:
@@ -267,6 +287,7 @@ WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);
267
287
```
268
288
269
289
**Security hardening:**
290
+
270
291
```sql
271
292
-- Prevent clients from setting app.* directly
272
293
REVOKE ALL ON SCHEMA pg_catalog FROM app_user;
@@ -278,14 +299,16 @@ REVOKE ALL ON SCHEMA pg_catalog FROM app_user;
278
299
**The footgun:** INSERT into child tables fails FK checks because RLS blocks SELECT on parent rows.
279
300
280
301
**Example failure:**
302
+
281
303
```sql
282
304
-- This INSERT fails even if customer exists
283
-
INSERT INTO orders (customer_id, tenant_id)
305
+
INSERT INTO orders (customer_id, tenant_id)
284
306
VALUES ('existing-customer-id', 'my-tenant');
285
307
-- ERROR: insert or update on table "orders" violates foreign key constraint
286
308
```
287
309
288
310
**Solution:**
311
+
289
312
```sql
290
313
-- Parent table needs SELECT policy for FK checks
291
314
CREATE POLICY customer_fk_visibility ON customers
@@ -298,16 +321,18 @@ USING (tenant_id = current_setting('app.tenant_id')::uuid);
298
321
**The footgun:** Global unique constraints reveal data existence across tenants.
0 commit comments