Row Level Security en Supabase: Errores Comunes que Dejan tu Base de Datos Abierta
Los 5 errores más comunes de Row Level Security en Supabase que dejan tu base de datos expuesta. USING(true), tablas sin RLS, service_role en el cliente y cómo corregirlos.
Row Level Security en Supabase: Errores Comunes que Dejan tu Base de Datos Abierta
Row Level Security es lo único que separa tu base de datos de Supabase de cualquier persona que tenga tu anon key. Y tu anon key es pública. Está en el código del cliente, visible en las DevTools del navegador.
La mayoría de tutoriales de Supabase te enseñan a crear tablas, hacer queries y configurar autenticación. Pocos se detienen en RLS con la profundidad que merece. El resultado: aplicaciones en producción con bases de datos completamente abiertas.
Este post cubre los 5 errores de RLS más comunes, por qué son peligrosos y cómo corregirlos con código que puedes copiar directamente.
¿Qué es RLS y por qué es obligatorio?
Supabase funciona diferente a un backend tradicional. Los clientes se conectan directamente a PostgreSQL a través de la API REST que Supabase genera automáticamente. Esa conexión usa la anon key, que es pública por diseño.
Cualquier persona puede abrir las DevTools de tu app, copiar la URL de Supabase y la anon key, y hacer queries directas a tu base de datos. Sin RLS, esas queries tienen acceso total.
// Cualquiera con tu anon key puede hacer esto
const { data } = await supabase.from('usuarios').select('*')
// Sin RLS: devuelve TODOS los usuarios con todos sus datos
// Con RLS: devuelve solo lo que las políticas permitenRow Level Security es una funcionalidad nativa de PostgreSQL que define políticas de acceso por fila. Cada vez que alguien hace una query, PostgreSQL evalúa las políticas y filtra automáticamente las filas que ese usuario puede ver o modificar.
-- Cada usuario solo puede ver sus propios datos
CREATE POLICY "usuarios_ver_propios" ON usuarios
FOR SELECT USING (auth.uid() = id);Con esta política activa, no importa si alguien hace SELECT * FROM usuarios. PostgreSQL automáticamente filtra y solo devuelve la fila del usuario autenticado.
RLS no es opcional en Supabase
En una app con backend tradicional, puedes controlar el acceso desde tu API. En Supabase, la base de datos está expuesta directamente al cliente. RLS es tu única capa de protección real. Si no lo configuras, tu base de datos está abierta.
La función auth.uid() devuelve el UUID del usuario autenticado. Supabase extrae esa información del JWT que el cliente envía automáticamente. Si no hay usuario autenticado, auth.uid() retorna null.
Error 1: Tablas sin RLS habilitado
El error más básico y el más peligroso. Cuando creas una tabla en Supabase, RLS viene deshabilitado por defecto. Supabase muestra un warning en el dashboard, pero muchos lo ignoran.
Verificar el estado de RLS
SELECT schemaname, tablename, rowsecurity
FROM pg_tables
WHERE schemaname = 'public'
ORDER BY tablename;Si rowsecurity es false, esa tabla está completamente abierta.
Habilitar RLS
ALTER TABLE usuarios ENABLE ROW LEVEL SECURITY;
ALTER TABLE notas ENABLE ROW LEVEL SECURITY;
ALTER TABLE pedidos ENABLE ROW LEVEL SECURITY;RLS habilitado sin políticas = acceso denegado para todos
Sin políticas + RLS habilitado = nadie puede acceder (ni siquiera usuarios autenticados). Necesitas crear políticas después de habilitar RLS.
El patrón seguro: habilitar y crear políticas juntos
-- Paso 1: Crear la tabla
CREATE TABLE notas (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
user_id UUID REFERENCES auth.users(id) NOT NULL,
titulo TEXT NOT NULL,
contenido TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
-- Paso 2: Habilitar RLS inmediatamente
ALTER TABLE notas ENABLE ROW LEVEL SECURITY;
-- Paso 3: Crear las políticas
CREATE POLICY "usuarios_ven_sus_notas" ON notas
FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "usuarios_crean_sus_notas" ON notas
FOR INSERT WITH CHECK (auth.uid() = user_id);
CREATE POLICY "usuarios_editan_sus_notas" ON notas
FOR UPDATE USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);
CREATE POLICY "usuarios_eliminan_sus_notas" ON notas
FOR DELETE USING (auth.uid() = user_id);Haz esto en el mismo migration file. No lo dejes para después.
Error 2: USING(true) -- la política que no protege nada
USING(true) le dice a PostgreSQL: "cualquier persona puede acceder a cualquier fila, sin restricciones".
-- PELIGROSO: cualquiera puede leer todo
CREATE POLICY "allow_all_read" ON datos FOR SELECT USING (true);
-- PELIGROSO: cualquiera puede insertar lo que quiera
CREATE POLICY "allow_all_insert" ON datos FOR INSERT WITH CHECK (true);
-- PELIGROSO: cualquiera puede modificar cualquier registro
CREATE POLICY "allow_all_update" ON datos
FOR UPDATE USING (true) WITH CHECK (true);Cuándo USING(true) es aceptable
Solo en políticas SELECT de datos genuinamente públicos:
-- OK: catálogo de productos que cualquiera puede ver
CREATE POLICY "productos_lectura_publica" ON productos
FOR SELECT USING (true);
-- Pero INSERT, UPDATE, DELETE deben estar restringidos
-- MEJOR: filtrar solo los publicados
CREATE POLICY "posts_publicados" ON posts
FOR SELECT USING (publicado = true);La corrección
-- MAL: cualquiera lee todo
CREATE POLICY "ver_datos" ON datos FOR SELECT USING (true);
-- BIEN: cada usuario ve solo sus datos
CREATE POLICY "ver_datos_propios" ON datos
FOR SELECT USING (auth.uid() = user_id);
-- MAL: cualquiera inserta lo que quiera
CREATE POLICY "insertar_datos" ON datos FOR INSERT WITH CHECK (true);
-- BIEN: solo puedes insertar registros a tu nombre
CREATE POLICY "insertar_datos_propios" ON datos
FOR INSERT WITH CHECK (auth.uid() = user_id);USING vs WITH CHECK
USING controla qué filas puedes leer (SELECT) o afectar (UPDATE, DELETE). WITH CHECK controla qué filas puedes crear (INSERT) o en qué puedes convertir una fila existente (UPDATE). En políticas UPDATE necesitas ambas.
Error 3: No cubrir todas las operaciones
Creas una política SELECT perfecta y te olvidas de que INSERT, UPDATE y DELETE son operaciones completamente independientes. Cada una necesita su propia política.
-- Solo protege la lectura
CREATE POLICY "leer_propias" ON notas
FOR SELECT USING (auth.uid() = user_id);
-- Falta: INSERT, UPDATE, DELETEEl comportamiento depende del estado de RLS
Con RLS habilitado: las operaciones sin política están denegadas por defecto. Con RLS deshabilitado: todas las operaciones están abiertas. El error está en asumir que una política SELECT protege toda la tabla.
Las cuatro operaciones que necesitas cubrir
-- 1. SELECT: ¿quién puede ver qué filas?
CREATE POLICY "notas_select" ON notas
FOR SELECT USING (auth.uid() = user_id);
-- 2. INSERT: ¿quién puede crear filas y con qué valores?
CREATE POLICY "notas_insert" ON notas
FOR INSERT WITH CHECK (auth.uid() = user_id);
-- 3. UPDATE: ¿quién puede modificar qué filas?
CREATE POLICY "notas_update" ON notas
FOR UPDATE
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);
-- 4. DELETE: ¿quién puede eliminar qué filas?
CREATE POLICY "notas_delete" ON notas
FOR DELETE USING (auth.uid() = user_id);El UPDATE necesita USING (qué filas puedes seleccionar para editar) y WITH CHECK (validar los nuevos valores). Sin WITH CHECK, un usuario podría reasignar sus registros a otra cuenta:
-- Sin WITH CHECK, esto podría funcionar:
UPDATE notas SET user_id = 'otro-usuario-uuid' WHERE id = 'mi-nota-uuid';Verificar cobertura de operaciones
SELECT policyname, cmd, qual, with_check
FROM pg_policies
WHERE tablename = 'notas';La columna cmd muestra qué operación cubre cada política. Verifica que las cuatro estén cubiertas.
Error 4: Confiar en el frontend para filtrar datos
// MAL: filtrar datos en el cliente
const { data } = await supabase
.from('pedidos')
.select('*')
.eq('user_id', currentUser.id)
// Un usuario puede modificar esta query desde DevTools
// BIEN: RLS filtra en la base de datos
// La política USING(auth.uid() = user_id) se aplica siempre
const { data } = await supabase.from('pedidos').select('*')El filtro .eq('user_id', currentUser.id) es una conveniencia para la UI, no una medida de seguridad. Cualquier persona puede abrir la consola del navegador, crear un cliente Supabase con la URL y anon key visibles en tu código, y hacer la query que quiera sin filtros.
Filtros del cliente vs políticas RLS
Puedes seguir usando filtros en el cliente para la UX (mostrar solo pedidos de un estado específico). Pero la seguridad la maneja RLS en la base de datos. Los filtros del cliente son para la UI; las políticas RLS son para la seguridad.
Lo mismo aplica para roles. No verifiques el rol en el frontend:
-- La base de datos verifica el rol, no el frontend
CREATE POLICY "solo_admins_leen_config" ON config_sistema
FOR SELECT USING (
EXISTS (
SELECT 1 FROM perfiles
WHERE perfiles.id = auth.uid()
AND perfiles.rol = 'admin'
)
);La regla es simple: nunca confíes en el cliente para decisiones de seguridad.
Error 5: service_role key en el cliente
La service_role key bypasea todas las políticas RLS. Es acceso admin total a tu base de datos.
// NUNCA hagas esto
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_SERVICE_ROLE!
)
// La service_role key bypasea RLS completamenteEl prefijo NEXT_PUBLIC_ hace que la variable esté disponible en el navegador. Cualquier usuario puede extraerla de las DevTools y tener acceso admin a toda tu base de datos.
La regla: service_role solo en el servidor
// CORRECTO: service_role en el servidor (sin NEXT_PUBLIC_)
import { createClient } from '@supabase/supabase-js'
const supabaseAdmin = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE!
)
// CORRECTO: anon key en el cliente
import { createBrowserClient } from '@supabase/ssr'
const supabase = createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)Revisa tus variables de entorno ahora
Busca en tu proyecto cualquier variable que contenga SERVICE_ROLE y verifica que ninguna tenga el prefijo NEXT_PUBLIC_. Si encuentras una, cámbiala inmediatamente y rota la key desde el dashboard de Supabase, porque la anterior ya pudo haber sido comprometida.
Checklist de variables de entorno para Supabase
# .env.local
# Públicas (accesibles desde el navegador) - OK
NEXT_PUBLIC_SUPABASE_URL=https://xxxxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIs...
# Privadas (solo servidor) - NUNCA con NEXT_PUBLIC_
SUPABASE_SERVICE_ROLE=eyJhbGciOiJIUzI1NiIs...
SUPABASE_DB_URL=postgresql://postgres:password@...Políticas correctas: ejemplos reales
Tabla de perfiles (público + privado)
ALTER TABLE perfiles ENABLE ROW LEVEL SECURITY;
-- SELECT: tu perfil + perfiles públicos de otros
CREATE POLICY "perfiles_select" ON perfiles
FOR SELECT USING (auth.uid() = id OR es_publico = true);
-- INSERT: solo puedes crear tu propio perfil
CREATE POLICY "perfiles_insert" ON perfiles
FOR INSERT WITH CHECK (auth.uid() = id);
-- UPDATE: solo puedes editar tu propio perfil
CREATE POLICY "perfiles_update" ON perfiles
FOR UPDATE USING (auth.uid() = id) WITH CHECK (auth.uid() = id);
-- DELETE: solo puedes eliminar tu propio perfil
CREATE POLICY "perfiles_delete" ON perfiles
FOR DELETE USING (auth.uid() = id);Tabla de productos (lectura pública, escritura admin)
ALTER TABLE productos ENABLE ROW LEVEL SECURITY;
-- Función helper para verificar admin
CREATE OR REPLACE FUNCTION es_admin()
RETURNS BOOLEAN AS $$
SELECT EXISTS (
SELECT 1 FROM perfiles
WHERE id = auth.uid() AND rol = 'admin'
);
$$ LANGUAGE sql SECURITY DEFINER;
-- SELECT: lectura pública (solo productos activos)
CREATE POLICY "productos_select" ON productos
FOR SELECT USING (activo = true);
-- INSERT, UPDATE, DELETE: solo admins
CREATE POLICY "productos_insert" ON productos
FOR INSERT WITH CHECK (es_admin());
CREATE POLICY "productos_update" ON productos
FOR UPDATE USING (es_admin()) WITH CHECK (es_admin());
CREATE POLICY "productos_delete" ON productos
FOR DELETE USING (es_admin());Tabla con roles (admin ve todo, usuario ve lo suyo)
ALTER TABLE pedidos ENABLE ROW LEVEL SECURITY;
-- SELECT: admins ven todo, usuarios ven sus pedidos
CREATE POLICY "pedidos_select" ON pedidos
FOR SELECT USING (auth.uid() = user_id OR es_admin());
-- INSERT: usuarios crean pedidos a su nombre
CREATE POLICY "pedidos_insert" ON pedidos
FOR INSERT WITH CHECK (auth.uid() = user_id);
-- UPDATE y DELETE: solo admins
CREATE POLICY "pedidos_update" ON pedidos
FOR UPDATE USING (es_admin()) WITH CHECK (es_admin());
CREATE POLICY "pedidos_delete" ON pedidos
FOR DELETE USING (es_admin());Firebase Rules: los mismos errores
Si vienes de Firebase, los patrones inseguros son equivalentes:
{
"rules": {
".read": true,
".write": true
}
}Esto es el equivalente a no tener RLS. La corrección en Firestore:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// BIEN: solo el dueño accede a sus notas
match /notas/{notaId} {
allow read, update, delete: if request.auth != null
&& resource.data.userId == request.auth.uid;
allow create: if request.auth != null
&& request.resource.data.userId == request.auth.uid;
}
}
}Los principios son idénticos sin importar la plataforma: nunca dejes acceso abierto por defecto, verifica la identidad en cada operación, y cubre lectura y escritura.
Verificar tus políticas
Antes de confiar en que tus políticas están bien, veríficalas. Puedes hacerlo manualmente con queries SQL o con herramientas que analicen tu SQL automáticamente.
Verifica tus políticas RLS
Verificador de políticas RLS gratuito -- Pega tu SQL y detecta configuraciones inseguras como USING(true), tablas sin políticas o políticas que no cubren todas las operaciones. El análisis corre en tu navegador, tu código no sale de tu máquina.
Verificación manual con SQL
-- Ver todas las políticas de una tabla
SELECT policyname, cmd, qual, with_check
FROM pg_policies
WHERE tablename = 'tu_tabla';
-- Encontrar tablas SIN RLS habilitado
SELECT tablename
FROM pg_tables
WHERE schemaname = 'public' AND rowsecurity = false;Probar como usuario anónimo
import { createClient } from '@supabase/supabase-js'
const supabaseAnon = createClient(
'https://tu-proyecto.supabase.co',
'tu-anon-key'
)
const { data, error } = await supabaseAnon.from('notas').select('*')
// Si data tiene registros, tus políticas son insuficientes
// Si error dice "permission denied" o data está vacío, RLS funcionaPrueba en un ambiente de desarrollo
No hagas estas pruebas en producción. Usa un proyecto de Supabase separado para testing o el ambiente local con supabase start.
Checklist antes de deploy
- Todas las tablas en
publictienen RLS habilitado - Cada tabla tiene políticas para SELECT, INSERT, UPDATE y DELETE
- No hay
USING(true)en políticas INSERT, UPDATE o DELETE sin justificación - La service_role key no tiene prefijo
NEXT_PUBLIC_ - Los filtros de seguridad están en RLS, no en el código del cliente
- Las políticas UPDATE incluyen
USINGyWITH CHECK
Preguntas frecuentes
¿Qué es Row Level Security en Supabase?
Row Level Security (RLS) es una funcionalidad de PostgreSQL que controla qué filas puede ver o modificar cada usuario. En Supabase es crítico porque los clientes se conectan directamente a la base de datos usando la anon key. Sin RLS, cualquier persona con esa key puede leer y modificar toda tu base de datos.
¿Qué pasa si no habilito RLS en una tabla de Supabase?
Si RLS está deshabilitado, cualquier request con la anon key tiene acceso total a esa tabla. Supabase muestra un warning en el dashboard cuando detecta tablas sin RLS. Cada tabla sin RLS es una puerta abierta.
-- Encontrar tablas sin RLS
SELECT tablename, rowsecurity
FROM pg_tables
WHERE schemaname = 'public' AND rowsecurity = false;¿USING(true) es siempre malo?
No siempre. USING(true) en una política SELECT es válido para datos públicos como un catálogo de productos. El problema es usarlo en políticas INSERT, UPDATE o DELETE, o en tablas con datos sensibles. Si usas USING(true), documenta la razón.
¿Puedo usar la service_role key en el frontend?
Nunca. La service_role key bypasea completamente RLS y da acceso total a la base de datos. Solo debe usarse en el servidor (Server Components, API routes, Server Actions). Si ya la expusiste, rota la key inmediatamente desde el dashboard de Supabase.
¿Cómo verifico que mis políticas RLS están bien configuradas?
Revisa tus políticas en el dashboard de Supabase (Authentication > Policies), ejecuta queries de prueba con diferentes roles, o usa el verificador de RLS de datahogo que analiza tu SQL automáticamente. Lo clave es probar con un usuario sin autenticar y verificar que solo ve lo que debería.
Conclusión
Los errores de RLS son silenciosos. Tu aplicación funciona perfectamente, las queries devuelven datos, los usuarios están contentos. Pero debajo de la superficie, la base de datos está abierta.
Los cinco errores más comunes:
- Tablas sin RLS habilitado -- el error más básico y el más destructivo
- USING(true) sin intención -- políticas que existen pero no protegen nada
- Operaciones sin cubrir -- proteger SELECT pero olvidar INSERT, UPDATE, DELETE
- Filtrar en el frontend -- confiar en el cliente para decisiones de seguridad
- service_role en el cliente -- la llave que anula todo tu trabajo de RLS
Habilita RLS en cada tabla, define políticas para las cuatro operaciones, usa auth.uid() para vincular datos a usuarios, y mantén la service_role key en el servidor.
Si estás construyendo con Supabase y NextJS, revisa la guía completa de Supabase con NextJS para la configuración inicial, la guía de seguridad en aplicaciones NextJS para las capas de seguridad de la aplicación, y el checklist de seguridad antes de deploy para no dejar nada abierto en producción.
Preguntas frecuentes
¿Qué es Row Level Security en Supabase?
Row Level Security (RLS) es una funcionalidad de PostgreSQL que permite controlar qué filas puede ver o modificar cada usuario. En Supabase es especialmente importante porque los clientes se conectan directamente a la base de datos usando la anon key. Sin RLS, cualquier persona con esa key puede leer y modificar toda tu base de datos.
¿Qué pasa si no habilito RLS en una tabla de Supabase?
Si RLS está deshabilitado, cualquier request con la anon key tiene acceso total a esa tabla: puede leer todos los registros, insertar datos, actualizarlos y eliminarlos. Es como tener una base de datos sin contraseña. Supabase muestra un warning en el dashboard cuando detecta tablas sin RLS.
¿USING(true) es siempre malo?
No siempre. USING(true) en una política SELECT es válido para datos públicos que cualquiera debería poder ver, como un catálogo de productos o posts de un blog público. El problema es usarlo en políticas INSERT, UPDATE o DELETE, o en tablas con datos sensibles. La clave es ser intencional: si usas USING(true), asegúrate de que realmente quieres que esos datos sean públicos.
¿Puedo usar la service_role key en el frontend?
Nunca. La service_role key bypasea completamente RLS y da acceso total a la base de datos. Solo debe usarse en el servidor (Server Components, API routes, Server Actions). Si la expones en el frontend, cualquier usuario puede extraerla de las DevTools y tener acceso admin a toda tu base de datos.
¿Cómo verifico que mis políticas RLS están bien configuradas?
Puedes revisar manualmente tus políticas en el dashboard de Supabase (Authentication > Policies), ejecutar queries de prueba con diferentes roles, o usar herramientas que analizan tu SQL y detectan patrones inseguros automáticamente. Lo importante es probar con un usuario sin autenticar y verificar que solo ve lo que debería.
Articulos relacionados
OWASP Top 10: Guía Práctica para Desarrolladores Web
Guía práctica del OWASP Top 10 en español. Las 10 vulnerabilidades más críticas en aplicaciones web con ejemplos de código, prevención en Next.js y Node.js, y checklist de seguridad.
Archivos .env Expuestos: Cómo Verificar si tu Sitio Filtra Secretos
Guía para detectar si tu sitio web expone archivos .env, .git y configuraciones sensibles. Verificación manual, protección en Next.js y Vercel, y remediación.
Security Headers: Cómo Verificar y Configurar los Headers de Seguridad de tu Sitio
Guía práctica para verificar y configurar security headers en tu sitio web. HSTS, CSP, X-Frame-Options y más con ejemplos para Next.js y Vercel.