Guía de Diseño de Schema Multi-Tenant con RLS en Supabase para Producción
Traducción al Español
He enviado tres aplicaciones SaaS multi-inquilino en Supabase en los últimos dos años. La primera fue un desastre. No porque Supabase me fallara -- no lo hizo -- sino porque fundamentalmente malentendí cómo Row Level Security (RLS) interactúa con la planificación de consultas a escala. La segunda fue mejor. La tercera maneja 2,000+ inquilinos con tiempos de consulta por debajo de 50ms en tablas con millones de filas.
Este artículo es todo lo que desearía que alguien me hubiera dicho antes de ese primer proyecto. Vamos a construir un esquema multi-inquilino real desde cero, configurar políticas RLS que realmente funcionen, y cubrir los casos extremos que solo aparecen cuando tienes tráfico real golpeando tu base de datos.
Tabla de Contenidos
- Enfoques de Multi-Inquilino en Supabase
- La Fundación: Esquema de Inquilino y Usuario
- Diseñar Políticas RLS Que No Destruyan el Rendimiento
- El Patrón tenant_id: Hacerlo Bien
- JWT Claims vs Búsquedas en Base de Datos
- Manejar Operaciones Entre Inquilinos
- Estrategia de Migración y Evolución del Esquema
- Benchmarks de Rendimiento y Optimización
- Endurecimiento de Seguridad para Producción
- Ejemplo de Esquema del Mundo Real
- Preguntas Frecuentes

Enfoques de Multi-Inquilino en Supabase
Antes de escribir una sola línea de SQL, aclaremos los tres enfoques para multi-inquilino y por qué uno de ellos gana para la mayoría de proyectos de Supabase.
| Enfoque | Nivel de Aislamiento | Complejidad | Costo por Inquilino | Mejor Para |
|---|---|---|---|---|
| Base de datos por inquilino | Más Alto | Muy Alto | $25+/mes por inquilino | Empresa, intensivo en cumplimiento |
| Esquema por inquilino | Alto | Alto | $5-15/mes por inquilino | SaaS de mercado medio |
| Esquema compartido + RLS | Medio | Medio | Centavos por inquilino | La mayoría de aplicaciones SaaS |
Base de datos por inquilino es lo que usarías si vendes a bancos o empresas de salud que literalmente requieren infraestructura separada. Supabase no lo facilita -- estarías administrando múltiples proyectos de Supabase.
Esquema por inquilino (usando esquemas PostgreSQL como tenant_123.projects) se ve atractivo pero se convierte en una pesadilla de mantenimiento. Cada migración se ejecuta contra cada esquema. Lo intenté una vez. Con 400 inquilinos, una migración simple ALTER TABLE tomó 45 minutos.
Esquema compartido con RLS es el punto dulce para el 90% de aplicaciones SaaS. Un conjunto de tablas, un conjunto de migraciones, y las políticas RLS de PostgreSQL manejan el aislamiento. Eso es lo que estamos construyendo aquí.
La Fundación: Esquema de Inquilino y Usuario
Comencemos con las tablas principales. Te voy a mostrar el esquema que uso en producción, no un juguete de tutorial.
-- Habilitar generación de UUID
create extension if not exists "uuid-ossp";
-- Inquilinos (organizaciones, espacios de trabajo, como quiera que los llames)
create table public.tenants (
id uuid primary key default uuid_generate_v4(),
name text not null,
slug text unique not null,
plan text not null default 'free' check (plan in ('free', 'pro', 'enterprise')),
settings jsonb not null default '{}',
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
-- Membresía de unión: conecta auth.users a inquilinos
create table public.tenant_memberships (
id uuid primary key default uuid_generate_v4(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
user_id uuid not null references auth.users(id) on delete cascade,
role text not null default 'member' check (role in ('owner', 'admin', 'member', 'viewer')),
created_at timestamptz not null default now(),
unique(tenant_id, user_id)
);
-- Crítico: índices en los que las políticas RLS se apoyarán
create index idx_tenant_memberships_user_id on public.tenant_memberships(user_id);
create index idx_tenant_memberships_tenant_id on public.tenant_memberships(tenant_id);
create index idx_tenant_memberships_user_tenant on public.tenant_memberships(user_id, tenant_id);
Dos cosas a notar. Primero, estoy usando una tabla de unión (tenant_memberships) en lugar de poner directamente un tenant_id en el perfil del usuario. Los usuarios pueden pertenecer a múltiples inquilinos -- ese es un requisito real para casi todas las aplicaciones SaaS. Segundo, esos índices no son opcionales. Sin ellos, cada verificación de RLS hace un escaneo secuencial en la tabla de membresías. He visto esto agregar 200ms+ a las consultas una vez que tienes algunos miles de membresías.
Diseñar Políticas RLS Que No Destruyan el Rendimiento
Aquí es donde la mayoría de tutoriales te fallan. Te muestran una política simple como:
-- NO HAGAS ESTO EN PRODUCCIÓN
create policy "Los usuarios pueden ver los datos de su inquilino"
on public.projects for select
using (
tenant_id in (
select tenant_id from public.tenant_memberships
where user_id = auth.uid()
)
);
Funciona. Pasará tus pruebas. Y luego lo desplegarás, obtendrás 500 usuarios, y te preguntarás por qué tu panel tarda 4 segundos en cargarse.
El problema es que PostgreSQL evalúa esta subconsulta para cada fila individual. El planificador de consultas puede a veces optimizarlo, pero a menudo no lo hace -- especialmente con JOINs involucrados.
El Patrón de Función Security Definer
Aquí está lo que realmente funciona en producción:
-- Crear una función que devuelva los IDs de inquilino del usuario
-- SECURITY DEFINER significa que se ejecuta con los permisos del creador de la función
-- Esto es crítico: evita RLS en la propia tabla de membresías
create or replace function public.get_user_tenant_ids()
returns setof uuid
language sql
security definer
stable
set search_path = public
as $$
select tenant_id
from public.tenant_memberships
where user_id = auth.uid();
$$;
-- Ahora usarlo en políticas
create policy "tenant_isolation_select"
on public.projects for select
using (tenant_id in (select public.get_user_tenant_ids()));
create policy "tenant_isolation_insert"
on public.projects for insert
with check (tenant_id in (select public.get_user_tenant_ids()));
create policy "tenant_isolation_update"
on public.projects for update
using (tenant_id in (select public.get_user_tenant_ids()))
with check (tenant_id in (select public.get_user_tenant_ids()));
create policy "tenant_isolation_delete"
on public.projects for delete
using (tenant_id in (select public.get_user_tenant_ids()));
¿Por qué es más rápido? La palabra clave STABLE le dice a PostgreSQL que la función devuelve el mismo resultado dentro de una única declaración. El planificador puede llamarla una vez y reutilizar el resultado. En mis puntos de referencia, esto reduce la sobrecarga de RLS en un 60-80% en consultas que tocan múltiples filas.
La Trampa de search_path
¿Ves ese set search_path = public en la función? No es opcional. Sin él, un usuario malintencionado podría potencialmente crear una función en otro esquema que sombreara auth.uid() y eludir tu seguridad. El equipo de Supabase ha escrito sobre esto, pero es fácil de perder.

El Patrón tenant_id: Hacerlo Bien
Cada tabla que contiene datos específicos del inquilino necesita una columna tenant_id. Sin excepciones. Incluso si la tabla tiene una clave foránea a otra tabla de ámbito de inquilino. Aquí está por qué:
-- Tu tabla de proyectos
create table public.projects (
id uuid primary key default uuid_generate_v4(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
name text not null,
created_at timestamptz not null default now()
);
-- Las tareas pertenecen a proyectos. Podrías pensar que tenant_id es redundante aquí.
create table public.tasks (
id uuid primary key default uuid_generate_v4(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
project_id uuid not null references public.projects(id) on delete cascade,
title text not null,
status text not null default 'todo',
created_at timestamptz not null default now()
);
-- Índice para RLS + patrones de consulta comunes
create index idx_projects_tenant on public.projects(tenant_id);
create index idx_tasks_tenant on public.tasks(tenant_id);
create index idx_tasks_project on public.tasks(project_id);
create index idx_tasks_tenant_project on public.tasks(tenant_id, project_id);
Sí, tenant_id en tasks está desnormalizado. Sí, es la decisión correcta. Sin él, la política RLS en tasks necesitaría hacer JOIN con projects para verificar el inquilino -- y ese JOIN sucede en cada consulta. Con el tenant_id desnormalizado, la verificación de RLS es una simple búsqueda de índice.
Aplicar consistencia con un trigger:
create or replace function public.verify_tenant_consistency()
returns trigger
language plpgsql
as $$
begin
if NEW.tenant_id != (
select tenant_id from public.projects where id = NEW.project_id
) then
raise exception 'tenant_id mismatch: task tenant does not match project tenant';
end if;
return NEW;
end;
$$;
create trigger check_task_tenant
before insert or update on public.tasks
for each row execute function public.verify_tenant_consistency();
JWT Claims vs Búsquedas en Base de Datos
Supabase te permite incrustar claims personalizados en el token JWT. Algunas personas usan esto para almacenar el ID del inquilino actual:
-- Leyendo desde JWT (rápido, pero tiene advertencias)
auth.jwt() -> 'app_metadata' ->> 'current_tenant_id'
Versus buscarlo en la base de datos cada vez:
-- Búsqueda en base de datos (más lenta, pero siempre actual)
select tenant_id from tenant_memberships where user_id = auth.uid()
| Aspecto | JWT Claims | Búsqueda en Base de Datos |
|---|---|---|
| Velocidad | ~0.1ms | ~1-5ms |
| Actualidad | Obsoleto hasta actualización de token | Siempre actual |
| Cambio de inquilino múltiple | Requiere actualización de token | Inmediato |
| Seguridad en revocación | Retraso hasta expiración de JWT | Inmediato |
| Complejidad de implementación | Más alta (necesita Edge Function) | Más baja |
Mi recomendación: usa búsquedas en base de datos con el patrón de función security definer para la mayoría de aplicaciones. La diferencia de rendimiento es negligible cuando tienes índices adecuados, y evitas una clase completa de errores alrededor de tokens obsoletos.
Si estás sirviendo a 10,000+ usuarios concurrentes y los milisegundos ahorrados importan, entonces sí, mueve el ID del inquilino activo al JWT. Necesitarás una Edge Function de Supabase (o un hook) para establecer el claim cuando los usuarios cambien de inquilino, y necesitarás manejar el flujo de actualización de token en el cliente.
Manejar Operaciones Entre Inquilinos
Algunas operaciones legítimamente necesitan cruzar límites de inquilinos. Paneles de administración, sistemas de facturación, agregación de análisis. Aquí está cómo manejarlos de forma segura.
Service Role Key (Usar con Moderación)
La clave del service role de Supabase evita RLS por completo. Úsala solo en código del lado del servidor -- nunca la expongas al cliente.
// Solo del lado del servidor (ruta de API de Next.js, Edge Function, etc.)
import { createClient } from '@supabase/supabase-js';
const supabaseAdmin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY! // NUNCA expongas esto
);
// Esto evita RLS - úsalo con extrema precaución
const { data } = await supabaseAdmin
.from('tenants')
.select('id, name, plan')
.eq('plan', 'enterprise');
Si estás construyendo en Next.js (hacemos mucho de esto en Social Animal), tus rutas de API y Componentes del Servidor son el lugar correcto para operaciones de service role.
Funciones de Base de Datos para Acceso Controlado Entre Inquilinos
Para un control más granular, crea funciones específicas:
create or replace function public.admin_get_tenant_stats(target_tenant_id uuid)
returns json
language plpgsql
security definer
set search_path = public
as $$
declare
result json;
caller_role text;
begin
-- Verificar que el llamador es un admin del inquilino objetivo
select role into caller_role
from tenant_memberships
where user_id = auth.uid() and tenant_id = target_tenant_id;
if caller_role not in ('owner', 'admin') then
raise exception 'Unauthorized: requires admin role';
end if;
select json_build_object(
'project_count', (select count(*) from projects where tenant_id = target_tenant_id),
'task_count', (select count(*) from tasks where tenant_id = target_tenant_id),
'member_count', (select count(*) from tenant_memberships where tenant_id = target_tenant_id)
) into result;
return result;
end;
$$;
Estrategia de Migración y Evolución del Esquema
Agregar tenant_id a tablas existentes es la migración que todos temen. Aquí está el enfoque que minimiza el tiempo de inactividad:
-- Paso 1: Agregar columna anulable
alter table public.some_existing_table
add column tenant_id uuid references public.tenants(id);
-- Paso 2: Rellenar (hacer esto en lotes para tablas grandes)
update public.some_existing_table set tenant_id = (
select tenant_id from public.projects
where projects.id = some_existing_table.project_id
)
where tenant_id is null;
-- Paso 3: Agregar restricción NOT NULL
alter table public.some_existing_table
alter column tenant_id set not null;
-- Paso 4: Agregar índice
create index concurrently idx_some_table_tenant
on public.some_existing_table(tenant_id);
-- Paso 5: Habilitar RLS y agregar políticas
alter table public.some_existing_table enable row level security;
create policy "tenant_isolation" on public.some_existing_table
for all using (tenant_id in (select public.get_user_tenant_ids()));
La palabra clave concurrently en la creación del índice es crucial -- sin ella, bloquearás la tabla durante toda la construcción del índice. En una tabla con un millón de filas, eso podrían ser minutos de tiempo de inactividad.
Benchmarks de Rendimiento y Optimización
Ejecuté puntos de referencia en un plan Supabase Pro ($25/mes, a principios de 2025) con 500,000 filas en la tabla tasks distribuidas entre 1,000 inquilinos.
| Patrón de Consulta | Sin RLS | RLS Ingenuo | RLS Optimizado | Sobrecarga |
|---|---|---|---|---|
| Seleccionar 50 filas (inquilino único) | 2.1ms | 18.4ms | 3.8ms | +81% |
| Insertar fila única | 1.2ms | 4.1ms | 1.9ms | +58% |
| Contar con filtro | 3.4ms | 22.1ms | 5.2ms | +53% |
| Unir entre 2 tablas | 4.8ms | 45.2ms | 8.1ms | +69% |
"RLS Optimizado" significa: funciones security definer, índices compuestos adecuados, tenant_id desnormalizado en tablas secundarias, y volatilidad de función STABLE.
El enfoque ingenuo (subconsultas en línea, índices faltantes) hace que RLS parezca lento. Optimizado, la sobrecarga es totalmente aceptable para cargas de trabajo de producción.
Consejos de Optimización Adicionales
- Usa índices compuestos con
tenant_idcomo columna principal:create index on tasks(tenant_id, status, created_at) - Particiona tablas grandes por
tenant_idsi algún inquilino único tiene millones de filas - Usa
pg_stat_statementspara encontrar consultas lentas -- Supabase expone esto en el panel bajo Database > Query Performance - Considera vistas materializadas para análisis entre inquilinos en lugar de ejecutar agregaciones caras en tiempo real
Endurecimiento de Seguridad para Producción
RLS es tu defensa principal, pero no debería ser tu única.
1. Negación Predeterminada
Siempre habilita RLS sin política predeterminada -- esto significa que si olvidas agregar una política a una nueva tabla, está bloqueada por defecto en lugar de estar completamente abierta.
alter table public.new_table enable row level security;
-- No agregues una política permisiva hasta que hayas considerado los patrones de acceso
2. Registro de Auditoría
create table public.audit_log (
id bigint generated always as identity primary key,
tenant_id uuid not null,
user_id uuid,
action text not null,
table_name text not null,
record_id uuid,
old_data jsonb,
new_data jsonb,
created_at timestamptz not null default now()
);
-- Trigger de auditoría genérico
create or replace function public.audit_trigger_func()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
begin
if TG_OP = 'DELETE' then
insert into audit_log(tenant_id, user_id, action, table_name, record_id, old_data)
values (OLD.tenant_id, auth.uid(), TG_OP, TG_TABLE_NAME, OLD.id, to_jsonb(OLD));
return OLD;
else
insert into audit_log(tenant_id, user_id, action, table_name, record_id, new_data, old_data)
values (
NEW.tenant_id, auth.uid(), TG_OP, TG_TABLE_NAME, NEW.id,
to_jsonb(NEW),
case when TG_OP = 'UPDATE' then to_jsonb(OLD) else null end
);
return NEW;
end if;
end;
$$;
3. Limitación de Velocidad en el Edge
RLS previene filtraciones de datos, pero no previene abuso. Usa Edge Functions de Supabase o tu middleware de Next.js para limitación de velocidad. Si estás construyendo con Astro, puedes manejar esto en tus puntos finales del servidor.
4. Prueba Tus Políticas
Escribe pruebas reales. Usa el entorno de desarrollo local de Supabase (supabase start) y prueba que:
- El usuario A no puede ver los datos del inquilino del usuario B
- Remover una membresía revoca inmediatamente el acceso
- Service role correctamente evita RLS
- Las políticas de inserción/actualización previenen la manipulación de tenant_id
// Ejemplo de prueba con Vitest
import { describe, it, expect } from 'vitest';
import { createClient } from '@supabase/supabase-js';
describe('Políticas RLS', () => {
it('debería prevenir acceso a datos entre inquilinos', async () => {
const userA = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
global: { headers: { Authorization: `Bearer ${tokenA}` } }
});
const { data, error } = await userA
.from('projects')
.select('*')
.eq('tenant_id', TENANT_B_ID);
expect(data).toHaveLength(0); // RLS debería filtrar estos
});
});
Ejemplo de Esquema del Mundo Real
Aquí hay un esquema condensado pero completo para un SaaS de gestión de proyectos. Esto es cercano a lo que he desplegado en producción.
-- Habilitar RLS en todas las tablas
alter table public.tenants enable row level security;
alter table public.tenant_memberships enable row level security;
alter table public.projects enable row level security;
alter table public.tasks enable row level security;
-- Inquilinos: los usuarios pueden ver inquilinos a los que pertenecen
create policy "view_own_tenants" on public.tenants for select
using (id in (select public.get_user_tenant_ids()));
-- Solo los propietarios pueden actualizar configuraciones de inquilino
create policy "owners_update_tenants" on public.tenants for update
using (
id in (
select tenant_id from public.tenant_memberships
where user_id = auth.uid() and role = 'owner'
)
);
-- Membresías: los miembros pueden ver otros miembros en sus inquilinos
create policy "view_tenant_members" on public.tenant_memberships for select
using (tenant_id in (select public.get_user_tenant_ids()));
-- Solo admins+ pueden gestionar membresías
create policy "admins_manage_members" on public.tenant_memberships for insert
with check (
tenant_id in (
select tenant_id from public.tenant_memberships
where user_id = auth.uid() and role in ('owner', 'admin')
)
);
-- Proyectos y Tareas: aislamiento de inquilino estándar
create policy "tenant_projects" on public.projects for all
using (tenant_id in (select public.get_user_tenant_ids()))
with check (tenant_id in (select public.get_user_tenant_ids()));
create policy "tenant_tasks" on public.tasks for all
using (tenant_id in (select public.get_user_tenant_ids()))
with check (tenant_id in (select public.get_user_tenant_ids()));
Si estás construyendo algo así y quieres un equipo que lo haya hecho antes, consulta nuestro trabajo de desarrollo de CMS sin encabezado -- hemos emparejado backends de Supabase con frontends sin encabezado para varios clientes.
Preguntas Frecuentes
¿Agrega Supabase RLS latencia significativa a las consultas?
Con políticas optimizadas (funciones security definer, índices adecuados, tenant_id desnormalizado), la sobrecarga es típicamente del 50-80% en la parte superior del tiempo de consulta bruto. Para una consulta que toma 3ms sin RLS, espera 5-6ms con ella. Esto es totalmente aceptable para uso en producción. El enfoque ingenuo puede agregar 10-20x de sobrecarga, por lo que la optimización importa mucho.
¿Puedo usar RLS con suscripciones de Realtime de Supabase? Sí. Supabase Realtime respeta las políticas RLS. Cuando un cliente se suscribe a cambios en una tabla, solo recibirá eventos para filas que esté autorizado a ver. Esto funciona automáticamente -- no necesitas agregar lógica de filtrado adicional en el cliente. Solo asegúrate de que tus políticas RLS sean eficientes, porque se evalúan para cada transmisión.
¿Cómo manejo el cambio de inquilino en la interfaz de usuario?
Almacena el ID del inquilino activo en el estado de tu aplicación (contexto de React, almacén de Zustand, etc.) y pásalo como filtro en tus consultas. Dado que RLS ya limita los resultados a los inquilinos a los que pertenece el usuario, el cambio es solo una cuestión de cambiar qué tenant_id filtras. No se necesita actualización de token si estás usando el enfoque de búsqueda en base de datos.
¿Debería usar la autenticación integrada de Supabase o un proveedor externo como Clerk?
Supabase Auth se integra naturalmente con RLS a través de auth.uid(). Si usas un proveedor externo, necesitarás sincronizar usuarios en Supabase y usar claims JWT personalizados o una tabla de mapeo. Me quedaría con Supabase Auth a menos que tengas una razón específica para no hacerlo -- ahorra un trabajo de integración significativo.
¿Cuántos inquilinos puede manejar un único proyecto de Supabase? Con multi-inquilino de esquema compartido, personalmente he ejecutado 2,000+ inquilinos en un plan Supabase Pro ($25/mes). El límite práctico depende del volumen de datos total y los patrones de consulta, no del conteo de inquilinos. Una instancia Supabase Pro con 2 vCPU y 1GB de RAM puede manejar tablas con decenas de millones de filas si tus índices son correctos.
¿Qué sucede si olvido habilitar RLS en una nueva tabla?
Si estás usando el cliente de Supabase con la clave anon, cualquier tabla sin RLS habilitado es accesible para cualquiera que tenga esa clave. Este es el mayor problema en Supabase. Configura una verificación de CI que verifique que todas las tablas en el esquema public tengan RLS habilitado. Puedes consultar pg_tables unido con pg_class para automatizar esta verificación.
¿Puedo usar RLS con Edge Functions de Supabase? Sí. Las Edge Functions pueden crear un cliente de Supabase usando la clave anon (se aplica RLS) o la clave del service role (RLS eludido). Usa la clave anon con el JWT del usuario para operaciones orientadas al usuario, y la clave del service role para tareas administrativas que necesiten acceso entre inquilinos.
¿Cómo migro de una aplicación de un solo inquilino a multi-inquilino?
Agrega tenant_id como columna anulable, rellénalo para todos los datos existentes (todas las filas obtienen el mismo ID de inquilino ya que era de un solo inquilino), luego agrega la restricción NOT NULL, índices, y políticas RLS. Prueba exhaustivamente con tus datos existentes antes de habilitar RLS -- una política incorrecta puede bloquear a todos tus usuarios. Usa el entorno de desarrollo local de Supabase para ensayar la migración.