RLS Security Patterns en Supabase: El Approach que el 90% de Developers Implementa Mal
RLS Security Patterns en Supabase: cómo diseñar Row Level Security policies para multi-tenant isolation sin sacrificar query performance.
Tu RLS Policy Es un Filter Que No Scalea
Tienes una tabla `orders` con 10.000 filas. Aplicas RLS con `tenant_id = auth.jwt() -> 'tenant_id'`.
Funciona. El usuario solo ve sus datos.
Pero cuando escalan a 5 millones de filas, tu query tarda 4 segundos. El explain plan muestra un sequential scan que RLS injecta en cada consulta.
*El problema real no es la seguridad. Es que RLS sin arquitectura adecuada convierte tu base de datos en un filtro caro.*
La mayoría implementa RLS como medida de seguridad puntual. Pocos diseñan policies pensando en cómo interactúan con el query planner de PostgreSQL.
Este es el framework que separa los sistemas que escalan de los que se caen en producción.
Cómo PostgreSQL Ejecuta RLS Internamente
Row Level Security no es un WHERE clause que tú escribes. Es un policy engine que PostgreSQL aplica antes de devolver resultados.
Cuando ejecutas:
```sql
SELECT * FROM orders;
```
PostgreSQL realmente ejecuta:
```sql
SELECT * FROM orders WHERE tenant_id = current_setting('app.current_tenant');
```
El problema: `current_setting()` se evalúa por cada fila escaneada. Sin index en `tenant_id`, tienes un sequential scan con filtro.
El Query Planner No Sabe de Tus Policies
PostgreSQL optimiza queries. Pero no sabe que tu RLS policy filtra por `tenant_id` hasta que ejecuta la policy.
```sql
-- Esto se convierte en un sequential scan + filter
SELECT * FROM orders WHERE amount > 100;
-- El planner ve: SELECT * FROM orders
-- Luego aplica: WHERE tenant_id = X AND amount > 100
```
El resultado: PostgreSQL escanea todas las filas antes de filtrar por tenant.
Pattern 1: Bypass Policies con SECURITY DEFINER Functions
La manera más performante de acceder a datos es *bypassear RLS completamente* cuando el contexto ya está validado.
```sql
-- Función que se ejecuta con privilegios del owner
CREATE OR REPLACE FUNCTION get_tenant_orders(p_tenant_id UUID)
RETURNS SETOF orders
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
-- Validación explícita dentro de la función
IF p_tenant_id IS NULL THEN
RAISE EXCEPTION 'Tenant ID requerido';
END IF;
RETURN QUERY
SELECT * FROM orders
WHERE tenant_id = p_tenant_id;
END;
$$;
-- Policy solo para la función
CREATE POLICY bypass_rls_get_tenant_orders
ON orders
FOR SELECT
TO authenticated
USING (tenant_id = (current_setting('app.current_tenant', true))::uuid);
```
Ahora tu API llama `get_tenant_orders(tenant_id)` directamente. El query planner recibe una query con filtro directo, usa el index, y el rendimiento mejora drásticamente.
¿Cuándo Usar Bypass?
❌ NO lo uses para acceso directo desde el cliente (RLS existe para eso)
✅ Úsalo para queries complejas desde Edge Functions o APIs internas donde ya tienes validación de tenant
Pattern 2: Indexación Estratégica para RLS
El secreto para queries rápidas con RLS es diseñar indexes que el query planner pueda usar *antes* de aplicar la policy.
```sql
-- Index composite que incluye tenant_id primero
CREATE INDEX idx_orders_tenant_created
ON orders (tenant_id, created_at DESC);
CREATE INDEX idx_orders_tenant_status
ON orders (tenant_id, status)
WHERE status IN ('pending', 'processing');
-- Partial index para queries comunes
CREATE INDEX idx_orders_tenant_pending
ON orders (id, amount, created_at)
WHERE tenant_id IS NOT NULL AND status = 'pending';
```
El orden de columnas importa. PostgreSQL usa el index de izquierda a derecha.
```sql
-- Queries que filtran por tenant_id primero se benefician
SELECT * FROM orders
WHERE tenant_id = 'xxx' AND created_at > '2026-01-01';
-- Queries sin tenant_id no usan este index
SELECT * FROM orders WHERE created_at > '2026-01-01';
```
RLS Hints para el Query Planner
En PostgreSQL 15+, puedes usar `pg_settings` para pasar hints al planner:
```sql
-- En tu Edge Function o aplicación
SET app.current_tenant = 'your-tenant-id';
SET app.rls_enabled = true;
-- PostgreSQL ahora puede optimizar sabiendo que siempre filtras por tenant
EXPLAIN SELECT * FROM orders WHERE amount > 100;
```
Pattern 3: JWT Claims como Filtro Primera Clase
Supabase inyecta JWT claims en `auth.jwt()` automáticamente. Pero muchos developers no saben que puedes acceder a claims anidados y usarles en policies.
```sql
-- Supabase JWT típico
-- {
-- "sub": "user-uuid",
-- "tenant_id": "tenant-uuid",
-- "role": "admin",
-- "https://myapp.com/user_metadata": { "plan": "enterprise" }
-- }
-- Policy que usa claim anidado
CREATE POLICY tenant_isolation_with_plan
ON documents
FOR ALL
TO authenticated
USING (
tenant_id = (auth.jwt() ->> 'tenant_id')::uuid
AND (
auth.jwt() -> 'https://myapp.com/user_metadata' ->> 'plan'
) IN ('enterprise', 'business')
);
```
El Error de Usar auth.uid() Solo
❌ MAL:
```sql
CREATE POLICY bad_isolation ON orders
FOR SELECT USING (tenant_id IN (
SELECT tenant_id FROM user_tenants WHERE user_id = auth.uid()
));
```
Esta subquery se ejecuta por cada fila. En 5M de filas, tienes 5M de subqueries.
✅ BIEN:
```sql
CREATE POLICY good_isolation ON orders
FOR SELECT USING (
tenant_id = (auth.jwt() ->> 'tenant_id')::uuid
);
```
El JWT ya contiene el `tenant_id`. No necesitas hacer lookup en cada query.
Pattern 4: Row Security con Roles Múltiples
En sistemas multi-tenant, necesitas diferentes niveles de acceso. No es lo mismo un usuario normal que un admin de tenant.
```sql
-- Roles en Supabase
-- authenticated: usuario logueado
-- service_role: acceso de backend sin limitaciones de RLS
-- Policy para usuarios normales
CREATE POLICY user_read_own_tenant
ON orders
FOR SELECT
TO authenticated
USING (
tenant_id = (auth.jwt() ->> 'tenant_id')::uuid
AND NOT (auth.jwt() ->> 'role' = 'service_role')
);
-- Policy para admins de tenant
CREATE POLICY admin_full_tenant_access
ON orders
FOR ALL
TO authenticated
USING (
tenant_id = (auth.jwt() ->> 'tenant_id')::uuid
AND (auth.jwt() ->> 'role' = 'tenant_admin')
);
```
Service Role: Cuándo Hacer Bypass Real
El service_role de Supabase *no tiene RLS* por defecto. Esto es peligroso pero útil para ciertos casos.
```sql
-- Si usas service_role, valida manualmente
-- En tu Edge Function:
const { data, error } = await supabase
.from('orders')
.select('*')
.eq('tenant_id', validatedTenantId); // Validas manualmente antes de la query
```
❌ NUNCA hagas:
```sql
-- Esto expone TODOS los datos
supabase.service_role.from('orders').select('*')
```
✅ SIEMPRE:
```sql
// Valida primero, luego query
const tenantId = validateTenant(request);
supabase.service_role.from('orders').select('*').eq('tenant_id', tenantId);
```
Framework: Implementación Paso a Paso
Step 1: Define tu Modelo de Tenancy
Antes de escribir una línea de SQL, decide:
```
Opción A: Tenant como columna en todas las tablas
Opción B: Schema separado por tenant (más complejo, mejor aislamiento físico)
Opción C: Base de datos separada por tenant (overkill para la mayoría)
```
Para el 95% de aplicaciones, Opción A con RLS bien diseñado es suficiente.
Step 2: Configura JWT con Claims de Tenant
En tu sistema de autenticación, incluye `tenant_id` en el JWT:
```javascript
// Al crear el JWT después de login
const token = await signJwt({
sub: user.id,
tenant_id: user.tenant_id,
role: user.role,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 3600
});
```
Step 3: Diseña Policies Defensivas
```sql
-- Siempre incluye CHECK adicional en la policy
CREATE POLICY defensive_tenant_isolation
ON orders
FOR ALL
TO authenticated
USING (
-- Filtro principal por JWT
tenant_id = (auth.jwt() ->> 'tenant_id')::uuid
)
WITH CHECK (
-- Validación adicional: no puedes insertar con otro tenant
tenant_id = (auth.jwt() ->> 'tenant_id')::uuid
);
```
Step 4: Benchmark Antes y Después
```sql
-- Mide impacto de RLS
EXPLAIN ANALYZE SELECT * FROM orders WHERE amount > 100;
-- Con RLS activo, el overhead debe ser < 10%
-- Si es más, necesitas indexes o bypass functions
```
El Anti-Pattern Que Destruye Rendimiento
❌ NUNCA HAGAS ESTO:
```sql
-- Policy con función que hace query adicional
CREATE POLICY terrible_policy ON orders
FOR SELECT USING (
tenant_id IN (
SELECT tenant_id FROM tenants
WHERE active = true
AND id IN (
SELECT tenant_id FROM user_tenants
WHERE user_id = auth.uid()
)
)
);
```
Tienes tres niveles de subqueries, cada una se ejecuta por fila. Para 1M de filas, eso son 3M de operaciones de lookup.
✅ SIEMPRE HAZ ESTO:
```sql
-- Todo viene del JWT, una evaluación por query
CREATE POLICY fast_policy ON orders
FOR SELECT USING (
tenant_id = (auth.jwt() ->> 'tenant_id')::uuid
);
```
Validación y Testing de Policies
Supabase incluye herramientas para probar policies antes de deployar:
```sql
-- Testear como usuario específico
SELECT * FROM orders LIMIT 10;
-- Verificar que RLS filtra correctamente
SELECT
auth.uid(),
(auth.jwt() ->> 'tenant_id')::uuid,
current_setting('app.current_tenant', true);
-- Forzar RLS en psql para testing
SET ROLE authenticated;
SET request.jwt.claim.tenant_id = 'test-tenant-uuid';
```
Resumen: Lo Que Necesitas Implementar Hoy
1. Añade `tenant_id` al JWT en tu sistema de autenticación
2. Crea indexes con `tenant_id` como primera columna en todas las tablas multi-tenant
3. Usa functions SECURITY DEFINER para queries complejas que ya validan contexto
4. Mide el overhead de RLS con EXPLAIN ANALYZE regularmente
5. Nunca anides subqueries en policies — cada nivel multiplica el coste
La seguridad de datos y el rendimiento no están en extremos opuestos. Con la arquitectura correcta, RLS puede ser transparente para el usuario e invisible para el query planner.
Tu próximo paso: abre tu cliente de Supabase y ejecuta `EXPLAIN ANALYZE` en tu tabla más grande con RLS activo. Si ves sequential scans, ya sabes qué optimizar.
Lee el artículo completo en brianmenagomez.com
Más sobre mis servicios en brianmenagomez.com
Herramientas: Conversor IAE CNAE · Gestorias cerca de ti · Calculadora IRPF

