La seguridad a nivel de fila (RLS, por Row Level Security) es una función de PostgreSQL que decide, fila por fila, qué filas puede leer o modificar un usuario, y la regla se ejecuta dentro de la base de datos. En una app de Supabase, RLS es lo único que separa sus tablas de cualquier visitante que copie la clave pública de su navegador. Si una tabla no tiene RLS, cualquiera puede leerla o editarla a través de la API pública.
Esta guía es para quienes crean aplicaciones web con un creador de apps con IA y quieren entender qué hacen las políticas generadas, para poder revisarlas. Cubre los cuatro tipos de política, los patrones habituales, el almacenamiento y una prueba con dos cuentas que lleva diez minutos.
Por qué RLS es obligatoria cuando la clave está en el navegador
Una aplicación web de Supabase habla con la base de datos directamente desde el navegador, así que la URL del proyecto y la clave pública (anon o publishable) viajan dentro del JavaScript que descarga cada visitante. Es así por diseño: la clave identifica su proyecto, no lo protege. La protección viene de las políticas de cada tabla.
La documentación de Supabase describe una política como una cláusula WHERE adicional que se añade a cada consulta, y señala que, una vez activada RLS en una tabla, no se puede acceder a ningún dato a través de la API con la clave pública hasta que se crean políticas. El peligro es lo contrario: una tabla del esquema public sin RLS está abierta. Cualquiera puede abrir la consola del navegador, llamar a su API con su propia clave pública y ejecutar select, insert, update o delete sobre ella.
La clave secreta (service role) es otra cosa. Ignora RLS, así que nunca debe aparecer en el código del cliente. Los secretos pertenecen al código de servidor, como las edge functions. El control de acceso defectuoso, en el que un usuario llega a datos que no son suyos, es la primera categoría del OWASP Top 10.
Qué ocurre sin RLS
Sin RLS, la app sigue funcionando en sus propias pruebas, y por eso el problema pasa desapercibido. Las pantallas solo piden sus propias filas. A la API le da igual lo que pidan las pantallas.
- Un visitante lista todas las filas de la tabla de pedidos, clientes o mensajes.
- Un usuario con sesión iniciada cambia el precio, el rol o el propietario de una fila que pertenece a otra persona.
- Un script borra una tabla entera, sin tener siquiera una cuenta.
Ocultar un botón en la interfaz no protege nada, ya que la API acepta cualquier petición que la clave permita. Si está comparando backends, nuestra guía sobre Supabase vs Firebase explica cómo gestiona cada uno las reglas de acceso.
Los cuatro tipos de política: select, insert, update, delete
Una política se aplica a una operación, y cada operación usa una o dos condiciones. USING filtra las filas que ya existen. WITH CHECK valida las filas que se escriben. La documentación de Supabase recomienda una política por operación.
| Operación | Condición utilizada | Qué controla |
|---|---|---|
| SELECT | USING | Qué filas existentes puede leer el usuario |
| INSERT | WITH CHECK | Si se permite la nueva fila |
| UPDATE | USING y WITH CHECK | Qué filas se pueden editar y en qué pueden convertirse. También necesita una política SELECT |
| DELETE | USING | Qué filas se pueden eliminar |
Este es un ejemplo completo para una tabla todos en la que cada fila tiene una columna user_id. Primero, active RLS:
alter table public.todos enable row level security;Después, una política por operación, cada una limitada con la cláusula TO a los usuarios con sesión iniciada:
create policy "Read own todos"
on public.todos for select
to authenticated
using ( (select auth.uid()) = user_id );
create policy "Create own todos"
on public.todos for insert
to authenticated
with check ( (select auth.uid()) = user_id );
create policy "Update own todos"
on public.todos for update
to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );
create policy "Delete own todos"
on public.todos for delete
to authenticated
using ( (select auth.uid()) = user_id );Dos detalles importan. La cláusula TO nombra el rol: sin ella, la política también se evalúa para las peticiones anónimas (el rol anon). Y auth.uid() devuelve el ID del usuario con sesión iniciada, o null si no hay nadie, en cuyo caso la comparación falla en silencio y ninguna fila coincide. Envolver la llamada como (select auth.uid()) permite que Postgres la calcule una vez por sentencia en lugar de una vez por fila, algo que Supabase recomienda por rendimiento.
Patrón 1: filas que pertenecen a un usuario
El patrón por usuario consiste en una columna user_id rellenada con el ID del usuario con sesión iniciada, más las cuatro políticas anteriores. Sirve para notas, tareas, perfiles, ajustes y cualquier dato privado. Un valor por defecto útil para la columna es auth.uid(), de modo que el navegador no tenga que enviarlo:
user_id uuid not null default auth.uid()
references auth.users (id) on delete cascadeIndexe la columna por la que filtran sus políticas, ya que la política se ejecuta en cada consulta:
create index todos_user_id_idx on public.todos (user_id);Patrón 2: equipos y organizaciones
Para datos compartidos, guarde la pertenencia en su propia tabla y deje que la política pregunte si el usuario pertenece al equipo de la fila. Basta una tabla de miembros con team_id y user_id:
create policy "Members read team projects"
on public.projects for select
to authenticated
using (
team_id in (
select team_id from public.team_members
where user_id = (select auth.uid())
)
);En la mayoría de las apps, las políticas de escritura son más estrictas que las de lectura: los miembros pueden leer, solo algunos pueden insertar o borrar. Ponga una columna role en team_members y compruébela en las políticas de insert, update y delete. Indexe team_members por user_id, porque una clave primaria compuesta que empieza por team_id no indexa user_id.
Patrón 3: roles de administrador
Una política de administrador debe leer el rol de un lugar que el usuario no pueda editar. La documentación de Supabase advierte de que raw_user_meta_data puede ser modificado por el usuario, así que no debe usarse para la autorización, mientras que raw_app_meta_data (app_metadata en el JWT) es fiable. La vía más sencilla y habitual es una tabla de administradores en la que solo escribe el propietario de la base de datos:
create policy "Admins read all invoices"
on public.invoices for select
to authenticated
using (
exists (
select 1 from public.admins
where user_id = (select auth.uid())
)
);Las políticas de la misma operación se combinan con OR, de modo que esta política añade el acceso de administrador a la política por usuario. Mantenga la tabla admins bien cerrada, con su propia RLS y sin política de insert; de lo contrario, un usuario podría hacerse administrador a sí mismo.
Políticas de almacenamiento para archivos subidos
Los archivos de Supabase Storage están protegidos por políticas RLS sobre la tabla storage.objects, y un bucket no acepta ninguna subida hasta que se añade una política. Una organización habitual guarda los archivos de cada usuario en una carpeta con el nombre de su ID de usuario, y la política comprueba la primera carpeta de la ruta:
create policy "Upload to own folder"
on storage.objects for insert
to authenticated
with check (
bucket_id = 'documents'
and (storage.foldername(name))[1] = (select auth.uid())::text
);
create policy "Read own files"
on storage.objects for select
to authenticated
using (
bucket_id = 'documents'
and (storage.foldername(name))[1] = (select auth.uid())::text
);Sobrescribir un archivo (upsert) necesita a la vez políticas de insert, select y update. Los objetos de un bucket público son legibles por cualquiera que tenga el enlace, así que guarde facturas, contratos y documentos de identidad en buckets privados. Consulte también la página de Supabase sobre el control de acceso de Storage.
Cómo probar sus políticas con dos cuentas
La única prueba que demuestra que RLS funciona es iniciar sesión como un usuario e intentar acceder a los datos de otro. El editor SQL no cuenta: se ejecuta con privilegios elevados e ignora sus políticas. Use la propia app, o la API con una sesión de usuario real.
- Cree dos cuentas, A y B, y añada algunas filas con cada una.
- Con la sesión de A, confirme que la lista muestra solo las filas de A.
- Todavía como A, llame directamente a la API para pedir las filas de B (un select con el user_id de B, un update sobre el ID de una fila de B, un delete). Cada intento debe devolver nada o fallar.
- Intente insertar una fila con el user_id de B. La política de insert debe rechazarla.
- Abra la app en una ventana privada, sin sesión, y pida las mismas tablas. Las tablas privadas no deben devolver nada.
- Repita los pasos con los archivos: A no debe poder descargar el archivo de B a partir de su ruta.
Errores frecuentes
| Error | Resultado | Solución |
|---|---|---|
| RLS nunca activada en una tabla | La tabla está abierta a cualquiera con la clave pública | Active RLS en todas las tablas del esquema public |
| Política sin cláusula TO | Se evalúa también para peticiones anónimas | Añada to authenticated |
| using (true) | Todas las filas son visibles para el rol | Úselo solo para datos que deban ser públicos |
| Política de update sin política de select | Las actualizaciones no cambian nada, en silencio | Añada una política de select equivalente |
| Rol leído de los metadatos del usuario | Los usuarios pueden concederse el rol a sí mismos | Use app_metadata o una tabla admins |
| Vista sobre una tabla protegida | Las vistas ignoran RLS por defecto | En Postgres 15 y posteriores, créela con security_invoker = true |
| Clave secreta en el código del cliente | Ignora todas las políticas | Manténgala solo en código de servidor |
Cómo lo gestiona Cadrant
Cadrant, nuestro creador, genera aplicaciones web en React sobre un proyecto de Supabase de su propia cuenta de Supabase. Cuando la IA crea una tabla, escribe la migración con políticas RLS para las tablas por usuario, incluidas las políticas de almacenamiento, y puede inspeccionar el esquema y las políticas actuales antes de modificarlos. Las claves secretas se quedan fuera del código del cliente: integraciones como Stripe pasan por edge functions, con los secretos guardados como variables de entorno. La configuración se describe en la documentación de conexión con Supabase y en la guía de creadores de apps con IA y Supabase.
La IA escribe las políticas a partir de lo que usted le dice, así que dígalo: "un cliente ve solo sus propias facturas, el responsable ve todas". Los prompts de nuestros ejemplos de prompts para una aplicación web muestran cómo. Las políticas generadas son un buen primer borrador, no una garantía. Haga siempre la prueba con dos cuentas antes de compartir la app con usuarios reales. Cadrant, como cualquier creador de apps con IA, puede equivocarse en una regla, y la prueba es la forma de descubrirlo.