Claude
Skills
Sign in
Back

postgres-rls

Included with Lifetime
$97 forever

MANDATORY when touching auth tables, tenant isolation, RLS policies, or multi-tenant database code - enforces Row Level Security best practices and catches common bypass vulnerabilities

Backend & APIs

What this skill does


# PostgreSQL Row Level Security

## Overview

Row Level Security (RLS) provides defense-in-depth for data isolation. When implemented correctly, it prevents data leaks even if application code misses a filter. When implemented incorrectly, it creates false security confidence while data bleeds between tenants.

**Core principle:** RLS is your last line of defense, not your only one. Get it wrong and you have a data breach.

**Announce at start:** "I'm applying postgres-rls to verify Row Level Security implementation."

## When This Skill Applies

This skill is MANDATORY when ANY of these patterns are touched:

| Pattern | Examples |
|---------|----------|
| `**/migrations/**/*tenant*` | migrations/001_add_tenant_id.sql |
| `**/migrations/**/*rls*` | migrations/005_enable_rls.sql |
| `**/migrations/**/*policy*` | migrations/010_create_policies.sql |
| `**/*policy*.sql` | db/policies.sql |
| `**/auth/**` | src/auth/context.ts |
| `**/*tenant*` | lib/tenant.ts, services/tenantService.ts |
| `**/*multi-tenant*` | docs/multi-tenant-architecture.md |

Check with:
```bash
git diff --name-only HEAD~1 | grep -iE '(tenant|rls|policy|auth.*sql|multi.?tenant)'
```

## The Critical Vulnerabilities

### 1. Superuser Bypass (CRITICAL)

Superusers and roles with `BYPASSRLS` ignore ALL policies.

```sql
-- DANGEROUS: Testing as superuser shows RLS "working" when it's bypassed
SET ROLE postgres;
SELECT * FROM orders;  -- Returns ALL rows, RLS ignored

-- CORRECT: Test as application role
SET ROLE app_user;
SELECT * FROM orders;  -- Returns only permitted rows
```

**Checklist:**
- [ ] Application connects as non-superuser role
- [ ] No roles have `BYPASSRLS` attribute
- [ ] Tests run as application role, NOT superuser

### 2. Table Owner Bypass (CRITICAL)

Table owners bypass RLS unless `FORCE ROW LEVEL SECURITY` is set.

```sql
-- INCOMPLETE: Owners bypass this
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;

-- COMPLETE: Everyone including owners must obey policies
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders FORCE ROW LEVEL SECURITY;
```

**Checklist:**
- [ ] All RLS tables have both ENABLE and FORCE
- [ ] Migration includes both statements

### 3. View Bypass (CRITICAL)

Views run with creator's privileges by default. Views owned by superusers bypass RLS entirely.

```sql
-- DANGEROUS: View owned by superuser bypasses RLS
CREATE VIEW all_orders AS SELECT * FROM orders;

-- SAFE (PostgreSQL 15+): Security invoker respects caller's RLS
CREATE VIEW user_orders
WITH (security_invoker = true)
AS SELECT * FROM orders;
```

**Checklist:**
- [ ] All views on RLS tables use `security_invoker = true` (PG15+)
- [ ] Views not owned by superuser roles
- [ ] Materialized views documented as bypassing RLS

### 4. USING vs WITH CHECK Mismatch (HIGH)

`USING` filters reads; `WITH CHECK` validates writes. Missing `WITH CHECK` allows inserting data you can't see.

```sql
-- INCOMPLETE: User can INSERT rows they can't SELECT
CREATE POLICY tenant_isolation ON orders
  USING (tenant_id = current_setting('app.tenant_id')::uuid);

-- COMPLETE: Both read and write protected
CREATE POLICY tenant_isolation ON orders
  USING (tenant_id = current_setting('app.tenant_id')::uuid)
  WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);
```

**Checklist:**
- [ ] All policies have both USING and WITH CHECK
- [ ] WITH CHECK logic matches security intent

### 5. Thread-Local Context Leakage (HIGH)

Connection pooling can leak tenant context between requests.

```sql
-- DANGEROUS: Context persists across pooled connections
SET app.tenant_id = 'tenant-123';

-- SAFE: Use SET LOCAL inside transaction (auto-resets)
BEGIN;
SET LOCAL app.tenant_id = 'tenant-123';
-- ... queries ...
COMMIT;  -- Context automatically cleared
```

**Application pattern:**
```typescript
// DANGEROUS: Leaks between requests
await db.query(`SET app.tenant_id = '${tenantId}'`);

// SAFE: Transaction-scoped context
await db.transaction(async (trx) => {
  await trx.raw(`SET LOCAL app.tenant_id = ?`, [tenantId]);
  // ... queries ...
});
```

**Checklist:**
- [ ] Always use `SET LOCAL` not `SET`
- [ ] Context set inside transactions
- [ ] Post-request handler resets context (defense in depth)

### 6. SQL Injection in Policy Functions (HIGH)

Functions used in policies can be injection vectors.

```sql
-- DANGEROUS: If current_tenant() uses user input unsafely
CREATE POLICY tenant_isolation ON orders
  USING (tenant_id = current_tenant());

-- The function itself must be injection-safe:
CREATE OR REPLACE FUNCTION current_tenant()
RETURNS uuid AS $$
BEGIN
  -- SAFE: Casts to UUID, not string concatenation
  RETURN current_setting('app.tenant_id')::uuid;
END;
$$ LANGUAGE plpgsql STABLE;
```

### 7. Materialized Views and Data Export (MEDIUM)

Materialized views don't respect source table RLS. Data exports may bypass policies.

```sql
-- DANGEROUS: Contains ALL tenants' data
CREATE MATERIALIZED VIEW order_stats AS
SELECT tenant_id, count(*) FROM orders GROUP BY tenant_id;

-- Background jobs with superuser access can export all data
```

**Checklist:**
- [ ] Materialized views documented as security-sensitive
- [ ] Export jobs run as application role
- [ ] Audit log for bulk data access

## Performance Considerations

### Index Policy Columns

```sql
-- Without index: Sequential scan on every query
CREATE POLICY tenant_isolation ON orders
  USING (tenant_id = current_setting('app.tenant_id')::uuid);

-- Add index for policy column
CREATE INDEX idx_orders_tenant_id ON orders(tenant_id);
```

### Wrap Functions in Subqueries

Functions called per-row are expensive. Wrap in subquery for single evaluation:

```sql
-- SLOW: Function called per row
CREATE POLICY access_check ON documents
  USING (user_has_access(auth.uid(), id));

-- FASTER: Evaluated once, cached
CREATE POLICY access_check ON documents
  USING ((SELECT auth.uid()) = owner_id);
```

### Use SECURITY DEFINER for Complex Checks

Avoid RLS policy chains with SECURITY DEFINER functions:

```sql
-- SLOW: RLS on permissions table also evaluated
CREATE POLICY access_check ON documents
  USING (id IN (SELECT document_id FROM permissions WHERE user_id = auth.uid()));

-- FASTER: Bypass RLS chain with SECURITY DEFINER
CREATE OR REPLACE FUNCTION user_document_ids(uid uuid)
RETURNS SETOF uuid AS $$
  SELECT document_id FROM permissions WHERE user_id = uid;
$$ LANGUAGE sql STABLE SECURITY DEFINER;

CREATE POLICY access_check ON documents
  USING (id IN (SELECT * FROM user_document_ids(auth.uid())));
```

### Denormalize for Performance

Store tenant_id on every table, even if "obvious" from joins:

```sql
-- SLOW: Must join to get tenant context
CREATE POLICY order_items_policy ON order_items
  USING (order_id IN (
    SELECT id FROM orders WHERE tenant_id = current_setting('app.tenant_id')::uuid
  ));

-- FAST: Direct column check
ALTER TABLE order_items ADD COLUMN tenant_id uuid;
CREATE POLICY order_items_policy ON order_items
  USING (tenant_id = current_setting('app.tenant_id')::uuid);
```

## Migration Pattern

### Safe RLS Migration

```sql
-- Step 1: Add column (if needed)
ALTER TABLE orders ADD COLUMN IF NOT EXISTS tenant_id uuid;

-- Step 2: Backfill data (batched for large tables)
UPDATE orders SET tenant_id = (
  SELECT tenant_id FROM customers WHERE customers.id = orders.customer_id
) WHERE tenant_id IS NULL;

-- Step 3: Add NOT NULL constraint
ALTER TABLE orders ALTER COLUMN tenant_id SET NOT NULL;

-- Step 4: Create index
CREATE INDEX CONCURRENTLY idx_orders_tenant_id ON orders(tenant_id);

-- Step 5: Enable RLS (both statements!)
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders FORCE ROW LEVEL SECURITY;

-- Step 6: Create policies
CREATE POLICY tenant_isolation ON orders
  FOR ALL
  USING (tenant_id = current_setting('app.tenant_id')::uuid)
  WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);

-- Step 7: Grant appropriate permissions
GRANT SELECT, INSERT, UPDATE, DELETE ON orders TO app_role;
```

## Testing RLS

Related in Backend & APIs