En resumen
raw_user_meta_data contiene lo que el navegador pasó a supabase.auth.signUp(). En una aplicación en producción, el trigger handle_new_user leía de ahí el rol del nuevo usuario, así que cualquiera podía registrarse con el id del rol Owner y quedarse con la cuenta. La corrección: el rol sale de una tabla que solo escribe el servidor, rellenada antes de enviar la invitación.
Fue en el control de horas de una firma de auditoría, una aplicación Supabase donde cada regla de acceso vive en la base de datos. El bug estaba en el trigger que crea un perfil para cada usuario nuevo.
¿Qué hacía el trigger?
Simplificado, era así:
create function public.handle_new_user()
returns trigger
language plpgsql
security definer set search_path = ''
as $$
declare
_role_id uuid := (new.raw_user_meta_data ->> 'role_id')::uuid;
begin
insert into public.profiles (id, full_name, role_id)
values (new.id, new.raw_user_meta_data ->> 'full_name', _role_id);
return new;
end;
$$;
El flujo de invitación ponía ahí el role_id correcto, así que en el uso normal funcionaba. Pero raw_user_meta_data son las options.data de un registro, y es el navegador quien decide qué lleva:
await supabase.auth.signUp({
email: 'me@example.com',
password: '...',
options: { data: { role_id: '<owner role uuid>' } },
})
La comprobación de permisos devolvía true para los owners antes de mirar nada más, así que ese único campo daba control total de la cuenta. La propia guía de Users de Supabase dice que los metadatos del usuario no deben usarse en políticas RLS ni en la lógica de autorización, porque el usuario puede editarlos sin ninguna comprobación.
¿Qué lo arregló?
Ahora el rol sale de una tabla privada a la que el cliente no puede tocar:
create table private.pending_invites (
email text primary key,
role_id uuid,
invited_by uuid,
created_at timestamptz default now()
);
revoke all on table private.pending_invites from anon, authenticated;
La función de invitación se ejecuta en el servidor, con el permiso de quien invita ya comprobado. Primero escribe la invitación pendiente y solo después crea el enlace de invitación. El trigger busca el rol por el correo y borra la fila. El nombre sigue viniendo de los metadatos, y no pasa nada: son datos para mostrar.
El orden importa. Una invitación crea el usuario al instante: la guía de Users de Supabase señala que invitar a un correo nuevo crea un usuario sin confirmar. El trigger se dispara en ese momento, así que la fila pendiente ya debe existir.
¿Cómo comprobar su propia aplicación?
Abra su función handle_new_user, o cualquier trigger sobre auth.users, y busque raw_user_meta_data. El nombre y el avatar de ahí están bien. El rol, la empresa, el plan, el inquilino o cualquier otra cosa que dé acceso debe salir de una tabla o de raw_app_meta_data, que el usuario no puede cambiar.
Después pruébelo: regístrese por la API con role_id o role: 'admin' en options.data. La cuenta nueva no debería recibir nada extra.
Este tipo de comprobación es el primer paso de mi método para probar RLS. En aplicaciones multi-tenant, vea también lo que RLS no cubre para un usuario en dos orgs. Los otros controles que ejecuto en cada app de Supabase están en mi lista de seguridad para apps Lovable. La primera que miro en el Dashboard es qué esquemas expone la Data API. La Auditoría de seguridad de Supabase cubre roles, grants, políticas y ajustes de auth con 45 reglas de OWASP Top 10:2025, no lo que un trigger copia de los metadatos del registro, así que esta sigue siendo una comprobación manual.