Supabase RLS: la vulnerabilidad #1 en apps vibe-coded
Row Level Security en Supabase explicado: por qué se olvida en vibe coding, cómo detectar tablas expuestas y qué políticas escribir para asegurar tu DB.
Si tu app usa Supabase y la auditás con VibeAuditt, hay altísima probabilidad de que el primer finding crítico sea “Row Level Security desactivado en X tablas”. No es casualidad: es el patrón más común en apps generadas con asistentes de IA.
TL;DR
- No es un caso aislado: Symbiotic Security (2026) escaneó 1.072 apps vibe-coded sobre Supabase y el 98% tenía al menos un fallo, el 16% uno crítico. El RLS insuficiente en Lovable está catalogado como CVE-2025-48757 (CVSS 9.3).
- La anon key de Supabase está en tu bundle JavaScript por diseño
- Sin RLS, cualquier visitante puede leer (y a veces escribir) cualquier tabla
- Habilitar RLS sin políticas bloquea todo (deny-by-default), que es seguro
- Para que tu app siga funcionando, escribí políticas con
auth.uid() = user_ido similar- Cuidado con políticas trampa:
USING (true)yUSING (auth.role() = 'authenticated')son equivalentes a no tener RLS
Por qué se olvida RLS en vibe coding
Cuando le pedís a un asistente “creame una app con login y un dashboard de tareas en Supabase”, el modelo genera código que:
- Crea las tablas (
CREATE TABLE tasks (id, title, user_id, ...)) - Usa
supabase.from('tasks').select()en el frontend - Funciona en el demo
Lo que casi nunca incluye:
ALTER TABLE tasks ENABLE ROW LEVEL SECURITY- Las políticas
CREATE POLICY ... - Test de seguridad para verificar que un usuario A no ve datos del usuario B
El happy path domina el contexto. RLS es un detalle de configuración que los modelos asumen que vos vas a manejar. Pero como nunca lo mencionan, vos no sabés que tenés que hacerlo.
Cómo funciona RLS en Supabase
Supabase expone tu Postgres vía REST con dos tipos de claves:
anon(anonymous): pública, viaja en tu bundle. Cliente sin sesión la usa.service_role: privada, bypassa RLS. Backend la usa, jamás se expone.
Cuando un cliente hace supabase.from('tasks').select() con la anon key, Postgres recibe la query y evalúa:
- ¿La tabla
taskstiene RLS habilitado? - Si no → retorna todas las filas (catastrófico si tenés data sensible)
- Si sí → evalúa cada política
FOR SELECTy retorna solo las filas que matchean
auth.uid() retorna el UUID del usuario logueado vía Supabase Auth. Si no hay sesión, retorna null. Eso te permite escribir políticas como “solo veo mis filas”:
CREATE POLICY "users see own tasks"
ON tasks FOR SELECT
USING (auth.uid() = user_id);
Postgres aplica esta política sobre cada fila considerada. El usuario solo recibe las que matchean.
Cómo detectar tablas sin RLS
Desde el dashboard de Supabase
- Andá a Authentication → Policies
- Vas a ver una tabla con todas tus tablas y un toggle “RLS”
- Cualquier tabla con RLS desactivado aparece con un warning rojo
Desde SQL
SELECT
schemaname,
tablename,
rowsecurity
FROM pg_tables
WHERE schemaname = 'public'
ORDER BY rowsecurity, tablename;
rowsecurity = false → tabla sin RLS. Cualquier fila accesible vía anon key.
Como atacante (conceptual)
Sin necesidad de probing intrusivo: si tenés acceso al frontend de un SaaS Supabase, podés abrir DevTools y ejecutar en la consola:
// Las claves son públicas, en el bundle
const sb = supabase || createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
// Probá leer tablas comunes
const { data: users } = await sb.from('users').select('*');
const { data: profiles } = await sb.from('profiles').select('*');
const { data: orgs } = await sb.from('organizations').select('*');
Si alguna devuelve filas (sin estar logueado), esa tabla está expuesta. No hagas esto contra apps que no son tuyas sin autorización — es leer datos sin consentimiento.
VibeAuditt hace esta verificación en modo seguro: solo prueba lectura, no escribe, no escala privilegios, y solo reporta qué tablas respondieron con filas.
Cómo configurar RLS correctamente
Patrón 1 — Tabla con dueño por fila
Caso típico: cada fila tiene un user_id que indica quién es el dueño.
-- Esquema
CREATE TABLE notes (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES auth.users(id),
title text NOT NULL,
body text NOT NULL,
created_at timestamptz DEFAULT now()
);
-- Habilitar RLS
ALTER TABLE notes ENABLE ROW LEVEL SECURITY;
-- Políticas separadas por operación (más explícito y fácil de auditar)
CREATE POLICY "notes select own" ON notes
FOR SELECT
USING (auth.uid() = user_id);
CREATE POLICY "notes insert own" ON notes
FOR INSERT
WITH CHECK (auth.uid() = user_id);
CREATE POLICY "notes update own" ON notes
FOR UPDATE
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);
CREATE POLICY "notes delete own" ON notes
FOR DELETE
USING (auth.uid() = user_id);
Diferencia entre USING y WITH CHECK:
USING: filtra qué filas son visibles para SELECT/UPDATE/DELETEWITH CHECK: valida que el row resultante de un INSERT/UPDATE cumple la condición
Para INSERT necesitás WITH CHECK. Para SELECT/DELETE solo USING. UPDATE típicamente requiere ambos para que no puedan “mover” la fila a otro user_id.
Patrón 2 — Tabla pública de solo lectura
Caso: un catálogo público que cualquiera puede ver pero solo admins editan.
ALTER TABLE products ENABLE ROW LEVEL SECURITY;
-- Cualquiera puede leer
CREATE POLICY "products read public" ON products
FOR SELECT
USING (true);
-- Solo admins pueden modificar
-- (admins definidos en una tabla "roles" o columna en auth.users metadata)
CREATE POLICY "products admins modify" ON products
FOR ALL
USING (
auth.uid() IN (SELECT user_id FROM roles WHERE role = 'admin')
);
Cuidado: FOR ALL cubre SELECT + INSERT + UPDATE + DELETE. Combinala con WITH CHECK apropiado o desglosá en políticas separadas si necesitás finer-grain.
Patrón 3 — Multi-tenant con organizaciones
Cada user pertenece a una organización; las filas pertenecen a la organización.
CREATE TABLE projects (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
org_id uuid NOT NULL REFERENCES organizations(id),
name text NOT NULL
);
CREATE TABLE org_members (
user_id uuid NOT NULL REFERENCES auth.users(id),
org_id uuid NOT NULL REFERENCES organizations(id),
role text NOT NULL DEFAULT 'member',
PRIMARY KEY (user_id, org_id)
);
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
ALTER TABLE org_members ENABLE ROW LEVEL SECURITY;
-- Un user ve los proyectos de las orgs a las que pertenece
CREATE POLICY "projects select via membership" ON projects
FOR SELECT
USING (
org_id IN (
SELECT org_id FROM org_members WHERE user_id = auth.uid()
)
);
Importante: por performance, indexá org_members(user_id) para que el subselect no haga full-scan en cada query.
Errores comunes en políticas RLS
Trampa 1 — USING (true)
CREATE POLICY "open" ON tabla FOR ALL USING (true); -- ❌
Equivalente a no tener RLS. Si lo encontrás en tu schema, es probable que alguien lo agregó “temporalmente para debugging” y nunca lo sacó.
Trampa 2 — auth.role() = 'authenticated' como única condición
CREATE POLICY "authed" ON tabla FOR ALL
USING (auth.role() = 'authenticated'); -- ❌
Parece restrictivo, pero cualquiera puede crearse una cuenta con supabase.auth.signUp() en segundos. Esto es trivialmente sorteable.
Trampa 3 — Olvidar WITH CHECK en UPDATE
CREATE POLICY "update own" ON notes
FOR UPDATE
USING (auth.uid() = user_id); -- sin WITH CHECK
Sin WITH CHECK, un usuario puede “regalar” su fila a otro user_id, escribiendo update notes set user_id = '<otro-uuid>'. El resultado: la fila ya no es suya pero la operación se ejecutó.
Correcto:
CREATE POLICY "update own" ON notes
FOR UPDATE
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);
Trampa 4 — Olvidar políticas separadas para ANON vs AUTHENTICATED
Supabase tiene dos roles principales: anon (sin sesión) y authenticated (con sesión). Por default tus políticas aplican a ambos. Si querés que algo sea visible solo si hay sesión:
CREATE POLICY "profiles authed only" ON profiles
FOR SELECT
TO authenticated
USING (true);
El TO authenticated restringe la política. Sin él, también se evalúa para clientes anon.
Trampa 5 — Storage buckets también requieren políticas
Si subís archivos a Supabase Storage, los buckets son tablas internas y también necesitan RLS. Andá a Storage → Policies y configurá políticas equivalentes para cada bucket.
-- Política para que cada user vea solo sus archivos en bucket 'avatars'
CREATE POLICY "avatars select own"
ON storage.objects FOR SELECT
USING (
bucket_id = 'avatars'
AND auth.uid()::text = (storage.foldername(name))[1]
);
Verificá tu setup
Después de habilitar RLS y crear políticas, verificá con estos pasos:
-
Sin sesión (incógnito o
await supabase.auth.signOut()):const { data, error } = await supabase.from('tu_tabla').select('*'); console.log({ data, error });Esperás
data: []odata: nullconerrorindicando RLS. Si recibís filas, alguna política está mal. -
Logueado como user A, intentás leer datos de user B:
-- Como user A SELECT * FROM notes WHERE user_id != auth.uid();Debe retornar 0 filas.
-
Intentás insertar con un
user_idque no es tuyo:await supabase.from('notes').insert({ user_id: 'otro-user-uuid', title: 'hack', body: '...', });Debe fallar con un error de RLS policy violation.
Si los 3 tests pasan, RLS está funcionando para esa tabla. Repetí por cada tabla del schema.
Auditoría automatizada
Configurar RLS manualmente por cada tabla puede llevar horas en un schema mediano. VibeAuditt detecta automáticamente:
- Tablas sin RLS habilitado
- Tablas con RLS habilitado pero sin políticas (que en realidad están bloqueadas)
- Tablas accesibles desde la anon key
Si querés un reporte detallado con la lista exacta de tablas a configurar y SQL listo para copiar, auditá tu SaaS gratis. Tres auditorías por mes sin tarjeta, y el plan Pro agrega monitoring semanal automático que te avisa si rompés algo en un release futuro.
Para más contexto sobre por qué los SaaS vibe-coded tienen estos problemas y los otros 11 errores comunes, leé los 12 errores de seguridad más comunes en SaaS vibe-coded.
Si querés profundizar por stack, tenemos guías dedicadas con los patrones específicos de cada herramienta: la guía de seguridad para apps con Supabase cubre RLS y configuración de la base en detalle, y la guía de seguridad para apps de Lovable repasa cómo Lovable expone la anon key y qué revisar antes de lanzar.
// preguntas frecuentes
Preguntas frecuentes
¿Qué es Row Level Security en Supabase?
Row Level Security (RLS) es una feature de PostgreSQL que permite definir políticas a nivel de fila. Cada vez que alguien intenta leer o modificar datos, Postgres evalúa la política y solo retorna las filas que matchean. Supabase la usa como su modelo de autorización principal.
¿Por qué es tan crítico habilitar RLS?
Porque la anon key de Supabase está en el bundle JavaScript de tu app. Cualquier visitante puede usarla para hacer queries directas a tu base de datos. Sin RLS, esas queries retornan todos los datos. Con RLS bien configurado, Postgres filtra automáticamente y solo expone lo que el usuario actual puede ver.
¿RLS hace lenta mi aplicación?
Sí, pero típicamente entre 5% y 15% en queries simples. Postgres evalúa la política por cada fila considerada. En tablas grandes con políticas complejas el impacto puede ser mayor, pero rara vez justifica desactivarlo. Usá índices en las columnas que la política consulta para mitigar.
Si activo RLS pero olvido crear políticas, ¿qué pasa?
La tabla queda completamente bloqueada para clientes anónimos y autenticados (deny-by-default). Es el comportamiento seguro: si nadie puede acceder, nadie puede filtrar datos. Pero también significa que tu app dejará de funcionar hasta que escribas las políticas necesarias.
¿Necesito RLS si solo uso el cliente desde el backend con service role?
Técnicamente la service_role key bypassa RLS, así que un backend que usa solo esa key no se ve afectado. Pero igual recomendamos habilitarlo: protege contra errores futuros si alguien expone accidentalmente la anon key, o si la app crece y agrega features cliente. Habilitarlo es defense-in-depth.
// escanear ahora
Audita tu SaaS gratis
Recibí un reporte de seguridad en menos de un minuto. Detectamos claves expuestas, RLS deshabilitado, headers de seguridad ausentes y más. Sin tarjeta, sin instalación.
10 auditorías gratis al mes · reportes detallados desde 9 USD