Volver al Blog
Bases de Datos Supabase Seguridad SaaS Core

Supabase RLS: Aislamiento Seguro Multitenant

• 11 min de lectura • Escrito por Marlon

TL;DR:

  • La Trampa: Políticas ingenuas de Row-Level Security (RLS) con subconsultas correlacionadas (organization_id IN (SELECT org_id FROM memberships WHERE user_id = auth.uid())) fuerzan escaneos secuenciales en PostgreSQL fila por fila, transformando consultas de 5ms en cuellos de botella de 8 segundos al superar 100k registros.
  • La Arquitectura: RLS multitenant de alta velocidad aprovechando claims JWT en sesión, funciones auxiliares STABLE SECURITY DEFINER con search_path blindado, índices B-Tree compuestos y pruebas automatizadas de penetración negativa.
  • El Impacto en Negocio: Garantiza cero fugas de datos entre organizaciones directamente en el motor de base de datos, reduce la sobrecarga de RLS a menos de 2ms y acelera auditorías de seguridad corporativas de meses a días.

La Fuga Silenciosa: Por Qué los Filtros en la Aplicación Fallan a Escala

En una plataforma SaaS B2B multicliente, delegar el aislamiento de datos exclusivamente al código de la aplicación o a consultas de ORM es un riesgo operacional latente.

Basta con que un desarrollador olvide una cláusula .where("tenant_id", currentTenant) en una ruta de API, o que un ingeniero junior ejecute una consulta SQL directa sin validar, para exponer documentos corporativos confidenciales a un competidor directo.

En ciberseguridad, este fallo se clasifica como BOLA (Broken Object Level Authorization) o IDOR (Insecure Direct Object Reference), considerado sistemáticamente por OWASP como la amenaza #1 en APIs cloud modernas.

Para un comprador corporativo, descubrir los registros de otra empresa dentro de su panel anula de inmediato la confianza comercial, activa penalizaciones contractuales y reprueba auditorías de due diligence técnico.

Para erradicar esta vulnerabilidad de forma definitiva, los arquitectos de software staff trasladan el perímetro de seguridad directamente al motor relacional mediante PostgreSQL Row-Level Security (RLS) en Supabase.

  1. FASE 01 Sesión

    Inyección de Contexto Auth

    Supabase procesa el JWT del cliente, inyectando variables de sesión e identificadores criptográficos (auth.uid()).

  2. FASE 02 Motor SQL

    Evaluación de Políticas RLS

    Postgres reescribe el AST de la consulta entrante, acoplando condiciones de seguridad antes de leer el disco.

  3. FASE 03 <2ms Scan

    Filtro Acelerado por Índices

    Índices B-Tree compuestos sobre (organization_id, id) eliminan escaneos secuenciales con búsquedas directas en RAM.

  4. FASE 04 Emisión

    Emisión Segura de Filas

    La base de datos emite estrictamente registros autorizados; datos de organizaciones ajenas se podan en el motor.


La Trampa de Rendimiento: Subconsultas Correlacionadas en Políticas RLS

Row-Level Security es un mecanismo de defensa extraordinariamente sólido, pero la redacción ingenua de políticas puede destruir el rendimiento de la base de datos.

Observa esta política común que abunda en tutoriales básicos:

-- ANTIPATRÓN DE RENDIMIENTO CRÍTICO: Subconsulta Correlacionada por Fila
CREATE POLICY naive_tenant_isolation ON documents
FOR SELECT
USING (
  organization_id IN (
    SELECT organization_id 
    FROM memberships 
    WHERE user_id = auth.uid()
  )
);

¿Qué Ocurre en el Planificador de Consultas de PostgreSQL?

Cuando tu backend ejecuta SELECT * FROM documents LIMIT 50, PostgreSQL evalúa la cláusula USING por cada una de las filas candidatas en la tabla:

  1. Si la tabla documents tiene 500,000 filas, Postgres puede ejecutar la subconsulta contra memberships ¡500,000 veces!
  2. El planificador cae en un Escaneo Secuencial (Seq Scan), ignorando los índices de la tabla y disparando el CPU al 100%.
  3. El tiempo de respuesta pasa de 4 milisegundos a más de 7 segundos bajo cargas moderadas.
EXPLAIN ANALYZE (Naive RLS):
Seq Scan on documents  (cost=0.00..84520.10 rows=2500 width=128) (actual time=142.10..7240.50 rows=50 loops=1)
  Filter: (SubPlan 1)
  SubPlan 1: Seq Scan on memberships (cost=0.00..32.50 rows=1 width=16) (executed 500,000 times!)
Total Runtime: 7,241.12 ms (CUELLO DE BOTELLA CRÍTICO)

Fundamentos Arquitectónicos: RLS Empresarial en Menos de 2ms

Para mantener consultas por debajo de los 2ms en tablas con millones de registros, la arquitectura de producción aplica tres disciplinas:

1. Funciones STABLE SECURITY DEFINER

PostgreSQL permite declarar funciones auxiliares con la etiqueta de volatilidad STABLE.

Cuando una función se marca como STABLE:

  • PostgreSQL garantiza que dentro de la misma transacción o ejecución de consulta SQL, la salida de la función no cambiará para los mismos parámetros.
  • El planificador ejecuta la consulta a memberships exactamente una sola vez, cachea el array de identificadores en memoria y lo reutiliza en todas las filas evaluadas.

Además, las funciones que consultan tablas de seguridad deben declararse con SECURITY DEFINER y fijar explícitamente SET search_path = public, pg_temp para neutralizar ataques de secuestro de esquemas (search path hijacking).

2. Custom Claims en JWT vs Consultas a Tablas

Para aplicaciones con alto volumen de tráfico, es posible erradicar por completo las consultas a la base de datos dentro de RLS usando Supabase Custom Claims.

Al autenticarse el usuario, un hook de Supabase Auth inyecta el org_id activo directamente en el payload del JWT:

-- Evaluación Instantánea en Memoria desde Claims JWT (Cero Consultas SQL)
CREATE POLICY jwt_tenant_isolation ON documents
FOR ALL
USING (
  organization_id = (auth.jwt() ->> 'org_id')::uuid
);

Esta expresión se evalúa en microsegundos directamente desde la memoria RAM, produciendo planes de ejecución idénticos a filtros directos indexados.

3. Índices Compuestos de Cobertura

Las políticas RLS inyectan condiciones WHERE implícitas en cada consulta. Si tu consulta busca por created_at o status, pero tu política RLS filtra por organization_id, PostgreSQL no podrá aprovechar índices de una sola columna eficazmente.

Toda tabla protegida por RLS multitenant debe contar con Índices B-Tree Compuestos cuya columna líder sea el identificador de la organización:

-- Índice Compuesto Óptimo para Consultas Multitenant
CREATE INDEX idx_documents_tenant_status 
ON documents (organization_id, status, created_at DESC);

Comparativa de Código: Subconsulta Ingenua vs Motor RLS de Producción

Naive: Subconsultas no Indexadas con Rutas Vulnerables

-- RLS Ingenuo: Subconsultas lentas, sin search_path, llaves foráneas sin indexar
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;

-- Problema 1: La subconsulta correlacionada se reevalúa en cada fila candidata
CREATE POLICY naive_invoices_policy ON invoices
FOR ALL
USING (
  organization_id IN (
    SELECT org_id FROM user_organizations WHERE user_id = auth.uid()
  )
);

-- Problema 2: SECURITY DEFINER sin search_path es vulnerable a secuestro de esquemas
CREATE OR REPLACE FUNCTION get_user_orgs()
RETURNS SETOF UUID AS $$
  SELECT org_id FROM user_organizations WHERE user_id = auth.uid();
$$ LANGUAGE sql SECURITY DEFINER;

Production: Motor RLS Multitenant Resiliente y Acelerado por Índices

-- Motor de Producción: Función STABLE en Memoria, search_path Blindado e Índices Compuestos
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";

-- 1. Tabla de Membresías Optimizada con Restricciones
CREATE TABLE public.memberships (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
    organization_id UUID NOT NULL,
    role VARCHAR(50) NOT NULL DEFAULT 'member',
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    CONSTRAINT uq_user_tenant UNIQUE (user_id, organization_id)
);

-- Índice Clave: Acelera la resolución de membresías a menos de 1ms
CREATE INDEX idx_memberships_user_tenant 
ON public.memberships (user_id, organization_id);

-- 2. Función Auxiliar Blindada con Cacheo STABLE y search_path Restringido
CREATE OR REPLACE FUNCTION public.get_auth_organizations()
RETURNS UUID[] 
LANGUAGE plpgsql
STABLE                      -- CRÍTICO: Indica al optimizador cachear resultados por consulta
SECURITY DEFINER            -- Ejecuta con privilegios del creador para leer membresías de forma segura
SET search_path = public, pg_temp -- DEFENSA: Neutraliza exploits de secuestro de search_path
AS $$
DECLARE
    org_ids UUID[];
BEGIN
    SELECT array_agg(organization_id)
    INTO org_ids
    FROM public.memberships
    WHERE user_id = auth.uid();
    
    RETURN COALESCE(org_ids, ARRAY[]::UUID[]);
END;
$$;

-- 3. Tabla de Entidad Protegida con Aislamiento Multitenant
CREATE TABLE public.invoices (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    organization_id UUID NOT NULL,
    customer_name VARCHAR(255) NOT NULL,
    amount_cents BIGINT NOT NULL CHECK (amount_cents >= 0),
    status VARCHAR(50) NOT NULL DEFAULT 'draft',
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Índice Compuesto de Cobertura: Hace que el filtrado RLS sea instantáneo
CREATE INDEX idx_invoices_tenant_status 
ON public.invoices (organization_id, status, created_at DESC);

-- 4. Habilitar RLS y Definir Políticas Declarativas de Producción
ALTER TABLE public.invoices ENABLE ROW LEVEL SECURITY;

-- POLÍTICA SELECT: Evalúa el array cacheado en menos de 1ms
CREATE POLICY select_invoices_isolation ON public.invoices
FOR SELECT
USING (
    organization_id = ANY(public.get_auth_organizations())
);

-- POLÍTICA INSERT: Evita suplantación de organization_id durante la creación
CREATE POLICY insert_invoices_isolation ON public.invoices
FOR INSERT
WITH CHECK (
    organization_id = ANY(public.get_auth_organizations())
);

-- POLÍTICA UPDATE: Restringe modificaciones e impide transferir registros entre clientes
CREATE POLICY update_invoices_isolation ON public.invoices
FOR UPDATE
USING (
    organization_id = ANY(public.get_auth_organizations())
)
WITH CHECK (
    organization_id = ANY(public.get_auth_organizations())
);

-- POLÍTICA DELETE: Estrictamente acotada al perímetro del tenant
CREATE POLICY delete_invoices_isolation ON public.invoices
FOR DELETE
USING (
    organization_id = ANY(public.get_auth_organizations())
);

Prueba Automatizada de Penetración Negativa en TypeScript

// Test de Integración: Verifica que el Tenant A NO pueda leer registros del Tenant B
import { createClient } from "@supabase/supabase-js";
import { describe, it, expect } from "vitest";

const SUPABASE_URL = process.env.SUPABASE_URL!;
const SUPABASE_ANON_KEY = process.env.SUPABASE_ANON_KEY!;

describe("Puerta de Seguridad Multitenant PostgreSQL RLS", () => {
  it("impide estrictamente que el Tenant A lea facturas del Tenant B", async () => {
    // 1. Autenticar cliente como usuario del Tenant A
    const clientA = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
    await clientA.auth.signInWithPassword({
      email: "finanzas@tenant-a.com",
      password: "SecurePassword123!"
    });

    const tenantBInvoiceId = "7a4b2c1d-0000-0000-0000-000000000002";

    // 2. Intentar consulta directa por ID sin filtros de organización
    const { data, error } = await clientA
      .from("invoices")
      .select("*")
      .eq("id", tenantBInvoiceId);

    // 3. Afirmación: PostgreSQL RLS retorna array vacío; ¡cero datos fugados!
    expect(error).toBeNull();
    expect(data).toHaveLength(0);
  });
});

Casos de Éxito Reales: Aislamiento Relacional a Escala

PróximaBolilla (proximabolilla.com): Telemetría Relacional de Alta Concurrencia

PróximaBolilla ejecuta simulaciones y analítica predictiva en tiempo real sobre millones de jugadas históricas.

  • El Problema: La plataforma almacena métricas históricas de combinación entre múltiples cuentas operadoras. Políticas iniciales con subconsultas RLS provocaron que la latencia trepara por encima de 1,800ms durante los sorteos nocturnos en vivo.
  • La Solución: Reemplazamos las subconsultas correlacionadas por funciones de seguridad cacheadas STABLE e implementamos índices compuestos en (organization_id, draw_date).
  • El Resultado: La latencia de consulta se redujo de 1,800ms a 1.8ms, operando de forma estable con cero filtraciones de datos entre operadores en millones de simulaciones evaluadas.

Strategio (Ganador Startup Perú 2G): BI Multitenant con Nube Menor a $50/Mes

Durante la construcción de Strategio, plataforma analítica de Business Intelligence desarrollada desde cero en 12 semanas:

  • El Problema: El sistema almacenaba márgenes brutos, costos y ventas confidenciales de cientos de comercios en una única instancia de PostgreSQL. Los clientes corporativos exigían aislamiento estricto, pero la infraestructura en la nube debía mantenerse estrictamente por debajo de $50 USD/mes.
  • La Solución: Implementamos PostgreSQL Row-Level Security nativo con claims de sesión en Supabase. Cada consulta analítica fue filtrada directamente por el motor relacional sin requerir middleware complejo en la capa de aplicación.
  • El Resultado: El sistema procesó millones de transacciones con cero fugas de información y un impacto mínimo en CPU, demostrando que una disciplina rigurosa en SQL ofrece seguridad de nivel enterprise con costos de infraestructura mínimos.

Pre-Flight Checklist para Arquitecturas Supabase RLS

Antes de desplegar políticas RLS multitenant a producción, verifica estos cinco puntos de control:

  • Eliminar Subconsultas Correlacionadas: ¿Tus políticas RLS emplean funciones STABLE o claims JWT en lugar de subconsultas no cacheadas organization_id IN (SELECT ...)?
  • Fijar search_path en Security Definers: ¿Todas las funciones SECURITY DEFINER definen explícitamente SET search_path = public, pg_temp para neutralizar ataques de inyección de esquemas?
  • Índices B-Tree Compuestos Activos: ¿Cada tabla con RLS cuenta con un índice compuesto cuya columna principal es organization_id?
  • Políticas CRUD Exhaustivas: ¿Definiste políticas específicas para todas las operaciones (SELECT, INSERT, UPDATE, DELETE) con sus cláusulas WITH CHECK correspondientes?
  • Suite de Pruebas de Penetración Negativa: ¿Tu pipeline de CI ejecuta pruebas automatizadas que verifican que consultas autenticadas contra identificadores de otros tenants retornan colecciones vacías?

Consultoría Arquitectónica

Diseñar políticas Row-Level Security robustas y de alto rendimiento en PostgreSQL requiere dominio del planificador de consultas, indexación estratégica y perímetros de seguridad relacionales.

En Coins5, Marlon ayuda a startups SaaS en crecimiento y empresas a blindar sus arquitecturas de bases de datos, erradicar riesgos de fugas de datos y superar auditorías de seguridad corporativas.

Si estás preparando tu base de datos Supabase para clientes enterprise o experimentas lentitud en consultas con RLS, agenda una llamada técnica de descubrimiento de 15 minutos o contacta directamente por WhatsApp.

¿Blindando Supabase RLS para Escala B2B?

Revisemos tus políticas RLS y planes de ejecución en PostgreSQL en una llamada técnica de descubrimiento de 15 minutos.

Marlon - Senior Architect
Autor & Arquitecto de Software

Marlon / Coins5

Senior Full Stack Developer & Arquitecto Cloud. Desarrollo de software a medida y MVPs robustos combinando Clean Architecture, diseño de sistemas de alta concurrencia y velocidad asistida por IA.

Agendar Llamada