seguridad·14 min de lectura

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.

javascript
// 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 permiten

Row 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.

sql
-- 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

sql
SELECT schemaname, tablename, rowsecurity
FROM pg_tables
WHERE schemaname = 'public'
ORDER BY tablename;

Si rowsecurity es false, esa tabla está completamente abierta.

Habilitar RLS

sql
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

sql
-- 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".

sql
-- 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:

sql
-- 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

sql
-- 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.

sql
-- Solo protege la lectura
CREATE POLICY "leer_propias" ON notas
  FOR SELECT USING (auth.uid() = user_id);
-- Falta: INSERT, UPDATE, DELETE
El 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

sql
-- 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:

sql
-- Sin WITH CHECK, esto podría funcionar:
UPDATE notas SET user_id = 'otro-usuario-uuid' WHERE id = 'mi-nota-uuid';

Verificar cobertura de operaciones

sql
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

typescript
// 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:

sql
-- 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.

typescript
// 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 completamente

El 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

typescript
// 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

bash
# .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)

sql
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)

sql
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)

sql
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:

json
{
  "rules": {
    ".read": true,
    ".write": true
  }
}

Esto es el equivalente a no tener RLS. La corrección en Firestore:

javascript
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

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

typescript
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 funciona
Prueba 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

  1. Todas las tablas en public tienen RLS habilitado
  2. Cada tabla tiene políticas para SELECT, INSERT, UPDATE y DELETE
  3. No hay USING(true) en políticas INSERT, UPDATE o DELETE sin justificación
  4. La service_role key no tiene prefijo NEXT_PUBLIC_
  5. Los filtros de seguridad están en RLS, no en el código del cliente
  6. Las políticas UPDATE incluyen USING y WITH 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.

sql
-- 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:

  1. Tablas sin RLS habilitado -- el error más básico y el más destructivo
  2. USING(true) sin intención -- políticas que existen pero no protegen nada
  3. Operaciones sin cubrir -- proteger SELECT pero olvidar INSERT, UPDATE, DELETE
  4. Filtrar en el frontend -- confiar en el cliente para decisiones de seguridad
  5. 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.

#supabase#seguridad#rls#base-de-datos#postgresql

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.