Qué falló realmente
Supabase expone tus tablas de Postgres a través de una API REST generada automáticamente. Tu frontend habla con esa API usando la anon key (también llamada publishable key). Esa clave está pensada para ser pública. Va incluida en tu bundle de JavaScript, y cualquiera puede leerla en DevTools.
Ese diseño está bien, siempre que todas las tablas tengan Row Level Security (RLS) activado con políticas sensatas. RLS es lo que decide qué filas puede ver cada petición. Como señala TheCyberSecGuru, citando la propia documentación de Supabase: "Sin RLS, cualquiera con tu anon key puede acceder a todos los datos."
Así que la cadena de fallos es esta:
- Una herramienta de IA (o un humano cansado) genera create table profiles (...).
- Nadie añade enable row level security.
- La app funciona perfectamente en las pruebas, porque el bug es precisamente que es demasiado permisiva.
- Cualquiera que copie la anon key de tu bundle puede ejecutar select * sobre esa tabla.
Si quieres el contexto de cómo funciona RLS, nuestra guía sobre Row Level Security en Supabase cubre lo básico. A continuación nos centramos en probar y solucionar.
Cómo revisar tu app (5 minutos)
1. Mírala desde fuera, como hicieron los investigadores. Abre tu sitio en producción, abre DevTools y busca supabase.co en el JavaScript cargado. Encontrarás la URL de tu proyecto y la anon key justo al lado. Después, desde una terminal, pide una fila de una tabla que consideres privada:
curl "https://YOUR-PROJECT.supabase.co/rest/v1/profiles?select=*&limit=1" \
-H "apikey: YOUR_ANON_KEY" \
-H "Authorization: Bearer YOUR_ANON_KEY"Lo que te dice la respuesta:
- Un array vacío [] o un error de permisos: bien, los usuarios anónimos no pueden leer esa tabla.
- Filas reales: tienes el mismo problema que esos 7.403 sitios.
Repite con cada tabla que contenga datos de usuarios: profiles, users, messages, orders, bookings, leads, waitlist, etc. Las tablas de lista de espera y de formularios de contacto son fáciles de olvidar y casi siempre contienen emails.
Ya que estás en el bundle, comprueba también que la clave que encontraste es la anon key y no la service_role key. El service role se salta RLS por completo. Si está en tu frontend, trátala como filtrada y rótala ya: cualquier cosa de tu bundle la puede leer cada visitante y cada script de terceros de la página, como descubrieron los usuarios de Polymarket, y los bots ya están buscando claves filtradas. Nuestro artículo sobre cómo rotar los secretos de tu app sin downtime explica la rotación, y API keys expuestas en JavaScript del lado del cliente cubre qué más suele acabar en los bundles.
2. Revísala desde dentro. En el editor SQL de Supabase, lista todas las tablas del esquema public y si tienen RLS activado:
select tablename, rowsecurity
from pg_tables
where schemaname = 'public'
order by rowsecurity, tablename;Cualquier fila con rowsecurity = false se puede leer (y posiblemente escribir) con la anon key. El Security Advisor del panel de Supabase también las señala. Lee sus avisos en lugar de descartarlos.
3. Busca casos de "RLS activado, pero abierto igualmente". Tener RLS activado no es la meta. Lista tus políticas:
select tablename, policyname, roles, cmd, qual
from pg_policies
where schemaname = 'public';Atento a qual = true en una política select que se aplique a anon o public. Es una política que dice "todo el mundo puede leerlo todo", lo que equivale a no tener RLS. Las herramientas de IA a veces generan exactamente esto para hacer desaparecer un error.
Cómo solucionarlo
Activa RLS en todas las tablas:
alter table public.profiles enable row level security;
alter table public.messages enable row level security;
-- repeat for every table in publicCon RLS activado y sin políticas, la tabla queda bloqueada: la anon key no recibe nada. Es un valor por defecto seguro. Ahora añade solo el acceso que de verdad necesitas.
Escribe políticas estrechas. Una configuración típica de "cada usuario solo puede ver y editar su propia fila":
create policy "Read own profile"
on public.profiles for select
to authenticated
using ((select auth.uid()) = user_id);
create policy "Update own profile"
on public.profiles for update
to authenticated
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);Cosas a tener en cuenta:
- Limita a authenticated, no a public, salvo que los datos sean realmente públicos (artículos de blog, fichas de producto).
- Separa lectura y escritura. Una tabla puede ser segura para lectura pública y aun así necesitar inserciones bien restringidas.
- Las tablas públicas también filtran columnas. Si una tabla profiles es pública para mostrar nombres, una columna de email o teléfono en esa misma tabla también es pública. Mueve los campos privados a una tabla aparte o expón una vista solo con las columnas seguras.
- Las vistas pueden saltarse RLS. En Postgres, las vistas se ejecutan por defecto con los privilegios del propietario. Crea las vistas con security_invoker = true para que se aplique el RLS de quien hace la llamada.
- Storage va aparte. Los buckets públicos sirven archivos a cualquiera que tenga la URL. Los documentos de identidad, facturas y avatares subidos por usuarios privados deben ir en buckets privados con políticas de storage.
Conviértelo en el comportamiento por defecto, no en una tarea de limpieza. La muestra de los investigadores abarca diez meses de apps nuevas, lo que significa que esto sigue pasando con cada tabla nueva. Haz que sea difícil olvidarlo:
- Añade una regla a las instrucciones de proyecto de tu herramienta de IA: cada create table en public debe ir seguido de enable row level security y políticas explícitas.
- Revisa las migraciones generadas en busca de create table sin su enable row level security correspondiente antes de hacer merge.
- Repite la comprobación desde fuera después de cada cambio de esquema, no solo en el lanzamiento.
Si usas Firebase en lugar de Supabase, el mismo tipo de error existe allí en forma de reglas de seguridad abiertas. Consulta Firebase Security Rules: deja de tener tu base de datos abierta.
Si encontraste datos expuestos
Corregir la política detiene las lecturas futuras, pero no deshace las pasadas. Sé honesto contigo mismo sobre qué era accesible y durante cuánto tiempo. Revisa los logs de la API de Supabase en busca de lecturas masivas inusuales desde IPs desconocidas, rota cualquier secreto que estuviera guardado en tablas legibles y consulta tus obligaciones de notificación si se expusieron datos personales como emails, documentos de identidad o datos de pago. En la UE, eso probablemente significa que se aplican las normas del RGPD sobre brechas de datos.
Checklist rápida
Antes de darlo por terminado, asegúrate de que:
- Todas las tablas de public tienen rowsecurity = true.
- Ninguna política select usa using (true) en tablas con datos privados.
- Las políticas están limitadas a authenticated y vinculadas a auth.uid().
- Las columnas privadas no viven en tablas de lectura pública.
- Las vistas usan security_invoker = true.
- En tu frontend solo está la anon key, nunca service_role.
- Los archivos privados están en buckets de storage privados.
- Vuelves a probar desde fuera después de cada cambio de esquema.
Revisa tu app desde fuera
Nuestra checklist de seguridad para apps hechas con IA cubre el resto de cosas que vale la pena revisar antes del lanzamiento. Lo incómodo de esta investigación es lo corriente que fue el método: clave pública, petición de solo lectura, los datos vuelven. Esa es exactamente la vista que tiene un atacante, y es la vista que deberías revisar con regularidad.
AppSafe escanea tu app en producción desde fuera. El escaneo completo verificado incluye comprobaciones de solo lectura de bases de datos de Supabase y Firebase abiertas y busca API keys expuestas en tus bundles de JavaScript, y los escaneos programados pueden avisarte cuando algo cambia tras un deploy. ¿Quieres ver cómo es un informe? Echa un vistazo al informe de ejemplo.
Escanea tu app gratis ahora y descubre qué puede ver un desconocido antes de que lo haga otro.
