← volver Monitor de terminal vintage brillando sobre un escritorio de madera tenue con papeles dispersos y luz suave nublada

Supabase RLS: Las políticas que escribo en cada proyecto

En 2021, un cliente SaaS vino a verme seis semanas después del lanzamiento. Su producto era una herramienta de gestión de proyectos. Interfaz bonita, incorporación sólida, retención decente. Entonces uno de sus usuarios beta notó algo: modificando el project_id en una solicitud GET, podían leer datos de otro usuario. Sin bypass de autenticación. Sin inyección SQL. Solo una política faltante. La tabla tenía RLS habilitado, pero la política SELECT estaba completamente abierta, USING (true). Alguien la había copiado de un tutorial y nunca volvió a verla.

Ese incidente se me quedó grabado. Desde entonces, trato RLS como arquitectura, no como algo secundario. Y después de construir más de 12,000 sitios y apps en Seahawk, tengo un puñado de políticas que escribo casi reflexivamente, antes de escribir una sola línea de código frontend.

Esta es esa lista.

Por qué RLS vale la fricción

Supabase se ejecuta en PostgreSQL, lo que significa que Row Level Security es una característica de primera clase en la base de datos, no un parche añadido. Cuando habilitas RLS en una tabla y un usuario la consulta a través del cliente de Supabase, cada fila se filtra automáticamente a través de tus políticas. Sin importar lo que tu capa API haga o no haga.

Esa última parte es el punto. He trabajado con equipos usando REST, GraphQL, funciones edge y trabajos en segundo plano todos golpeando la misma base de datos. Implementar control de acceso en la capa de aplicación en todos ellos es una pesadilla de coordinación. Implementarlo en la capa de base de datos es simplemente... listo.

Honestamente, la fricción de escribir políticas se paga sola la primera vez que un desarrollador junior olvida agregar una cláusula WHERE.

Hay una cosa que la gente malentiende. Habilitar RLS sin ninguna política no significa "acceso abierto". Significa sin acceso en absoluto para roles normales. Cada fila se deniega por defecto. Así que si habilitas RLS y tu app se rompe inmediatamente, por eso es.

Las cuatro tablas que siempre protejo con RLS primero

No todo necesita el mismo nivel de escrutinio. Pero estos cuatro tipos de tabla se bloquean antes de tocar cualquier otra cosa.

  • Tablas de perfil de usuario (profiles, users, accounts): La obvia. Los usuarios deberían leer su propia fila. Tal vez los administradores puedan leer todas. Nadie debería escribir en el perfil de otro.
  • Tablas de recursos/contenido (projects, documents, posts): Sea lo que sea lo principal en tu app. La propiedad es generalmente clara aquí.
  • Tablas de facturación y suscripción: Si estás usando Stripe y almacenando datos de plan, estado de suscripción o historial de facturas en Supabase, esto necesita estar bien asegurado. He visto apps que accidentalmente exponen fechas de fin de prueba a otros usuarios.
  • Registros de auditoría: Estos son de solo lectura para usuarios (si acaso). Solo el rol de servicio debería escribirlos.

Las políticas que realmente escribo

1. La política solo-propietario (Mi patrón más usado)

Esta es la que escribo más que cualquier otra cosa. Premisa simple: los usuarios solo pueden ver las filas que poseen.

`` create policy "Users can view own rows" on profiles for select using (auth.uid() = user_id); ``

auth.uid() es un auxiliar de Supabase que devuelve el UUID del usuario autenticado actualmente. Limpio, rápido, indexado si user_id está indexado. Lo combino con una política de inserción que establece user_id en auth.uid() por defecto, así los usuarios no pueden insertar filas fingiendo ser otro.

`` create policy "Users can insert own rows" on profiles for insert with check (auth.uid() = user_id); ``

La cláusula with check es para operaciones de escritura. using es para lecturas. Mucha gente confunde estas dos y termina con políticas que se ven correctas pero en realidad no previenen inserciones malas.

2. La Política de Org/Team (Aplicaciones Multi-Tenant)

Aquí es donde las cosas se ponen interesantes. Para cualquier SaaS multi-tenant, necesito que los usuarios vean filas que pertenecen a su organización, no solo a ellos mismos.

El patrón en el que termino: una tabla de uniones de membresías que vincula usuarios a organizaciones.

`` create policy "Org members can view org resources" on projects for select using ( exists ( select 1 from memberships where memberships.org_id = projects.org_id and memberships.user_id = auth.uid() ) ); ``

Seahawk tuvo un proyecto fintech donde la org tenía docenas de usuarios, algunos con roles de solo lectura, otros con acceso de escritura. Extendimos este patrón con una columna role en membresías y la usamos directamente en la política. Entonces un rol viewer no podía ejecutar UPDATE o DELETE a nivel de base de datos, punto final. No forzado por la API. Forzado por la base de datos.

3. La Política de Lectura Pública / Escritura del Propietario

Para contenido que es públicamente visible pero solo editable por el propietario. Publicaciones de blog, perfiles públicos, listados de productos.

``` create policy "Anyone can read published posts" on posts for select using (published = true);

create policy "Authors can update own posts" on posts for update using (auth.uid() = author_id) with check (auth.uid() = author_id); ```

Dos políticas separadas. Veo gente intentando combinarlas en una y terminar con lógica que es difícil de razonar. Mantenlas separadas. Postgres las combinará con OR automáticamente para la misma operación cuando sea necesario.

4. La Escotilla de Escape del Service Role

Algunas operaciones legítimamente necesitan eludir RLS. Trabajos en segundo plano, webhooks, scripts de administrador. Para estos uso la clave service_role, que elude RLS completamente.

Pero aquí está la cosa: nunca expongo la clave service_role en código frontend. Nunca. Vive en variables de entorno solo en el lado del servidor. He revisado bases de código donde estaba codificada en un directorio Next.js pages/. Esa es toda tu base de datos, completamente abierta.

Si estás usando funciones edge de Supabase, puedes usar el cliente service_role dentro de ellas de forma segura, porque las funciones edge se ejecutan del lado del servidor. La documentación propia de Supabase sobre auth y service roles vale la pena leer de principio a fin si no lo has hecho.

5. La Política de Anulación de Administrador

Para aplicaciones con un panel de administrador, agrego una política que otorga a los administradores acceso completo, verificado contra un rol almacenado en los metadatos JWT del usuario o una tabla user_roles separada.

`` create policy "Admins can do everything" on projects for all using ( exists ( select 1 from user_roles where user_roles.user_id = auth.uid() and user_roles.role = 'admin' ) ); ``

Solía almacenar roles en los custom claims JWT, que es más rápido (sin subconsulta), pero significa que tienes que re-emitir el JWT cada vez que cambia un rol. Para la mayoría de las aplicaciones, la subconsulta está bien. Si estás viendo problemas de rendimiento a escala, JWT custom claims a través de hooks de Supabase Auth es la opción.

Errores Comunes que He Cometido (y He Visto)

Déjame ser directo sobre las cosas que realmente me han afectado a mí o a mis clientes.

  1. Olvidar políticas de UPDATE y DELETE. Es fácil escribir una política SELECT y sentir que terminaste. No es así. Prueba las cuatro operaciones: SELECT, INSERT, UPDATE, DELETE. Ahora uso el probador de políticas integrado del dashboard de Supabase, pero durante años estuve escribiendo SQL crudo en psql y probando manualmente.
  2. Confusión entre USING y WITH CHECK. USING filtra qué filas puede ver una consulta. WITH CHECK valida si una operación de escritura es permitida. Para UPDATE, necesitas ambas: USING para controlar qué filas pueden ser objetivo, WITH CHECK para controlar cómo se ve la fila después de la actualización.
  3. Bucles de política recursiva. Si tu política en la tabla A consulta la tabla B, y la tabla B tiene una política que consulta la tabla A, obtendrás recursión infinita. Me pasó una vez con tablas teams y team_members que se referenciaban mutuamente. La solución: usar funciones security definer para romper el ciclo.
  4. No probar como usuario anónimo. Supabase te permite usar el rol anónimo (anon). Siempre prueba tus políticas como authenticated y anon. Uso Postman con diferentes tokens de auth para simular esto, alternando entre sin token, un token de usuario válido, y el token de un usuario diferente.
  5. Dejar RLS desactivado en buckets de almacenamiento. RLS se aplica también a la tabla storage.objects. Si creas un bucket de Supabase Storage y dejas esa tabla sin proteger, cualquiera puede leer tus archivos "privados" si adivina la ruta. Aprendí esto a la fuerza en un proyecto de cliente que almacenaba documentos cargados por usuarios.

Cómo pruebo mis políticas antes de desplegar

Este es mi proceso real, no una lista de verificación teórica.

  1. Escribe la política en el editor SQL de Supabase.
  2. Abre una segunda pestaña del navegador e inicia sesión como un usuario de prueba diferente.
  3. Intenta acceder a datos que deberían estar bloqueados. Confirma que están bloqueados.
  4. Intenta acceder a datos que deberían ser visibles. Confirma que funciona.
  5. Ejecuta un UPDATE y DELETE contra una fila que no te pertenece. Debería fallar.
  6. Revisa los logs de Supabase para cualquier error de política de seguridad a nivel de fila violada.

Para cualquier cosa complicada, especialmente políticas de organizaciones multiinquilino, escribo un pequeño script de prueba usando el cliente supabase-js con dos sesiones de usuario diferentes y compruebo los resultados esperados. Toma quizá 20 minutos escribirlo, pero ahorra horas de depuración en producción.

Cuándo NO usar RLS

RLS no siempre es la herramienta correcta.

Si estás construyendo una herramienta administrativa interna donde todos los usuarios son empleados de confianza, RLS añade complejidad sin mucho beneficio. Una simple verificación de autenticación del lado del servidor es suficiente. Si tu modelo de datos es tan complejo que las políticas requieren subconsultas anidadas 5 niveles de profundidad, probablemente sea mejor que hagas cumplir el control de acceso en una capa API con descomposición adecuada de servicios.

También: si estás usando Supabase puramente como backend con tu propia API enfrente (nunca exponiendo la URL de Supabase o la anon key a los clientes), RLS es opcional. La API se convierte en tu capa de seguridad. Dicho esto, sigo agregando políticas básicas en esos casos porque la defensa en profundidad vale la pena.

Mira, RLS es una herramienta. No una religión. Úsala donde simplifique y haga más seguro tu sistema. No la adoptes ciegamente porque un tutorial te lo dijo.

FAQ

¿Necesito RLS si solo estoy usando Supabase con una API del lado del servidor?

No estrictamente. Si tu frontend nunca toca Supabase directamente y todo pasa por tu propio servidor, tu API es la capa de seguridad. Pero aún así recomiendo agregar al menos políticas basadas en propietario como segunda línea de defensa. Si alguien encuentra un bug en tu API, RLS atrapa lo que se escapa.

¿RLS afecta el rendimiento?

Puede hacerlo, si tus políticas involucran subconsultas costosas en tablas grandes. La solución casi siempre es indexación. Asegúrate de que las columnas usadas en tus condiciones de política (user_id, org_id, etc.) tengan índices. En un proyecto el año pasado, agregar un índice en org_id redujo el tiempo de evaluación de política de ~40ms a menos de 2ms en una tabla con 800k filas.

¿Puedo usar RLS con Supabase Realtime?

Sí. Las suscripciones a Realtime respetan las políticas RLS. Si un usuario se suscribe a cambios en una tabla, solo recibirá eventos para filas que sus políticas le permiten ver. Esta es una de las decisiones de diseño genuinamente buenas en la arquitectura de Supabase.

¿Cuál es la diferencia entre `for all` y escribir políticas separadas?

for all crea una sola política que cubre SELECT, INSERT, UPDATE y DELETE. Es conveniente para patrones de anulación administrativa. Para todo lo demás, escribo políticas separadas por operación porque las condiciones generalmente son diferentes. SELECT podría permitir lecturas públicas mientras INSERT requiere propiedad. Las políticas separadas son más fáciles de entender cuando algo sale mal a las 11pm.

¿Cómo depuro una política que bloquea solicitudes que no debería?

Primero: verifica que auth.uid() realmente esté devolviendo un valor. Si el usuario no está autenticado, devuelve null y la mayoría de las políticas fallarán. Segundo: establece temporalmente la política a USING (true) para confirmar que la consulta en sí funciona. Tercero: añade una política de prueba que registre los valores que estás verificando (usando una función con definer de seguridad que genere un aviso). Los logs del dashboard de Supabase también muestran violaciones de RLS, lo que hace esto mucho menos doloroso que antes.

---

La seguridad a nivel de fila no es un trabajo glamoroso. Nadie escribe publicaciones de blog sobre la brecha que nunca ocurrió. Pero ese incidente en 2021 con los datos del proyecto expuestos me enseñó que la brecha entre "RLS habilitado" y "RLS implementado correctamente" es más amplia de lo que la mayoría cree. Estas políticas cierran esa brecha. Al menos lo hacen para mí.

← volver