Cómo crear una app multi-empresa con Supabase (equipos)
Un patrón para equipos y organizaciones en Supabase: las tablas, RLS por membresía, roles, cómo crear una organización y cómo probar el aislamiento.
Read in English →
La forma habitual de hacer una app multi-empresa (multi-tenant, con equipos u organizaciones) en Supabase es una sola base de datos compartida con una tabla organizations, una tabla memberships que une usuarios con organizaciones y les asigna un rol, y una columna org_id en cada tabla con datos de clientes. Después, Row Level Security deja pasar una fila solo si su org_id es una de las organizaciones del usuario, con una pequeña función security definer guardada en un esquema que la API no expone. No necesitas una base ni un proyecto de Supabase por cliente.
Aquí verás las tablas, la función auxiliar, las políticas, cómo crear una organización y cómo comprobar que un cliente no ve los datos de otro.
¿Qué tablas necesito?
Tres tipos: las organizaciones, el vínculo entre usuarios y organizaciones, y tus datos.
create table public.organizations (
id uuid primary key default gen_random_uuid(),
name text not null,
created_by uuid not null default auth.uid() references auth.users(id),
created_at timestamptz not null default now()
);
create table public.memberships (
org_id uuid not null references public.organizations(id) on delete cascade,
user_id uuid not null references auth.users(id) on delete cascade,
role text not null check (role in ('owner', 'admin', 'member')),
primary key (org_id, user_id)
);
create table public.projects (
id uuid primary key default gen_random_uuid(),
org_id uuid not null references public.organizations(id) on delete cascade,
name text not null
);
create index memberships_user_id_idx on public.memberships (user_id);
create index projects_org_id_idx on public.projects (org_id);
Cada tabla con datos de clientes lleva su org_id, también las tablas hijas (tareas dentro de proyectos, comentarios dentro de tareas). Una columna directa hace las políticas más simples y rápidas que unir hasta la tabla padre.
¿Por qué hace falta una función auxiliar?
Tus políticas tienen que leer memberships para saber a qué organizaciones pertenece el usuario. Si memberships tiene una política propia que a su vez lee memberships, Postgres responde infinite recursion detected in policy for relation "memberships". La solución habitual es una función que lea las membresías con los permisos de su dueño.
La guía de RLS de Supabase pone dos condiciones a las funciones security definer: fijar search_path = '' y no crearlas nunca en un esquema expuesto como public. Así que crea un esquema privado:
create schema if not exists private;
grant usage on schema private to authenticated;
create or replace function private.user_org_ids()
returns setof uuid
language sql stable security definer
set search_path = ''
as $
select org_id from public.memberships
where user_id = (select auth.uid())
$;
create or replace function private.user_admin_org_ids()
returns setof uuid
language sql stable security definer
set search_path = ''
as $
select org_id from public.memberships
where user_id = (select auth.uid()) and role in ('owner', 'admin')
$;
Como la función devuelve un conjunto y no depende de la fila, usarla como org_id in (select private.user_org_ids()) permite que Postgres la ejecute una sola vez por consulta.
¿Cómo son las políticas?
Activa RLS en las tres tablas y agrega las políticas. Para los datos de cada organización:
alter table public.projects enable row level security;
create policy "Miembros leen proyectos"
on public.projects for select to authenticated
using ( org_id in (select private.user_org_ids()) );
create policy "Miembros crean proyectos"
on public.projects for insert to authenticated
with check ( org_id in (select private.user_org_ids()) );
create policy "Miembros editan proyectos"
on public.projects for update to authenticated
using ( org_id in (select private.user_org_ids()) )
with check ( org_id in (select private.user_org_ids()) );
create policy "Admins borran proyectos"
on public.projects for delete to authenticated
using ( org_id in (select private.user_admin_org_ids()) );
El with check del update es importante: sin él, un miembro podría mover un proyecto a otra organización cambiando su org_id.
En las membresías, los miembros ven quién más está en sus organizaciones, y solo los administradores agregan o quitan personas:
alter table public.memberships enable row level security;
create policy "Miembros ven al equipo"
on public.memberships for select to authenticated
using ( org_id in (select private.user_org_ids()) );
create policy "Admins gestionan miembros"
on public.memberships for all to authenticated
using ( org_id in (select private.user_admin_org_ids()) )
with check ( org_id in (select private.user_admin_org_ids()) );
No olvides los permisos de la Data API. Desde el 30 de mayo de 2026, los proyectos nuevos de Supabase no exponen tablas nuevas automáticamente, y Supabase indica que los existentes siguen el mismo camino el 30 de octubre de 2026, así que agrega grant select, insert, update, delete on ... to authenticated para cada tabla en la migración que la crea.
¿Cómo crea alguien una organización?
Aquí suelen fallar los primeros intentos. El usuario inserta una organización, pero todavía no es miembro, así que no puede leerla, y tampoco puede crear la primera membresía porque eso es solo para administradores. Se resuelve con un trigger que agrega al creador como dueño:
create or replace function private.add_creator_as_owner()
returns trigger
language plpgsql security definer
set search_path = ''
as $
begin
insert into public.memberships (org_id, user_id, role)
values (new.id, new.created_by, 'owner');
return new;
end;
$;
create trigger organizations_add_owner
after insert on public.organizations
for each row execute function private.add_creator_as_owner();
alter table public.organizations enable row level security;
create policy "Crear organizaciones a nombre propio"
on public.organizations for insert to authenticated
with check ( created_by = (select auth.uid()) );
create policy "Miembros y creador leen la organización"
on public.organizations for select to authenticated
using (
id in (select private.user_org_ids())
or created_by = (select auth.uid())
);
La condición sobre created_by en la política de lectura permite que la app lea la organización nueva justo después de insertarla.
¿Cómo deberían funcionar las invitaciones?
Guárdalas en su propia tabla (org_id, email, role, un token aleatorio y una fecha de vencimiento) en la que solo los administradores pueden insertar. Aceptar la invitación debe pasar en el servidor, en una Edge Function o una función security definer, que verifica el token, comprueba que el correo del usuario con sesión coincide, crea la membresía y borra la invitación. Nunca dejes que el navegador inserte su propia membresía.
¿Conviene poner la organización en el JWT?
Se puede, con un Custom Access Token hook que agregue las organizaciones o roles del usuario como claims. La contra: los claims solo se actualizan cuando se renueva el token de sesión, así que si sacas a alguien de una organización, el cambio no aplica hasta que su token cambie. Con los índices puestos, la consulta de membresías de arriba suele bastar y siempre está al día.
¿Cómo compruebo que las organizaciones están aisladas?
- Crea dos organizaciones con un usuario cada una, y un tercer usuario que pertenezca a las dos.
- Entra como el primer usuario e intenta leer, editar y borrar por ID los proyectos de la segunda organización, desde la app y con llamadas directas a la API.
- Intenta insertar un proyecto con el
org_idde la otra organización. Tiene que fallar. - Quita al usuario compartido de una organización y confirma que pierde el acceso.
Repite estas pruebas cada vez que agregues una tabla. En un usuario ve datos de otro explicamos qué revisar en la app, y en ejemplos de políticas RLS cómo probar políticas en SQL. Para Storage, usa el org_id como primera carpeta de cada archivo y aplica la misma función en las políticas de storage.objects.
Respuestas rápidas
¿Cada cliente debería tener su propio proyecto de Supabase? Casi nunca. Multiplica migraciones, claves y costos. Tiene sentido sobre todo cuando un contrato exige separación física.
¿Dónde va la facturación? En la organización, no en el usuario: un cliente de Stripe por organización. Lo vemos en suscripciones con Stripe en tu app.
¿Un usuario puede estar en varias organizaciones? Sí, para eso está la tabla de membresías.
¿Quieres otra mirada sobre tu diseño?
Una revisión de seguridad de Supabase prueba el aislamiento tabla por tabla, y puedes escribirnos para conversar tu diseño. La revisión gratuita de tu app es un buen punto de partida.


