Em resumo
raw_user_meta_data guarda tudo o que o browser passou a supabase.auth.signUp(). Numa aplicação em produção, o trigger handle_new_user lia dali a função do novo utilizador, por isso qualquer pessoa podia registar-se com o id da função Owner e ficar dona da conta. A correção: a função vem de uma tabela em que só o servidor escreve, preenchida antes de o convite ser enviado.
Isto foi no registo de horas da empresa de auditoria, uma aplicação Supabase em que todas as regras de acesso vivem na base de dados. O bug estava no trigger que cria um perfil para cada novo utilizador.
O que fazia o trigger?
Simplificado, era assim:
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;
$$;
O fluxo de convite colocava ali o role_id certo, por isso no uso normal funcionava. Mas raw_user_meta_data é o options.data de um registo, e é o browser que decide o que lá entra:
await supabase.auth.signUp({
email: 'me@example.com',
password: '...',
options: { data: { role_id: '<owner role uuid>' } },
})
A verificação de permissões devolvia verdadeiro para os owners antes de olhar para qualquer outra coisa, por isso esse único campo dava controlo total da conta. O próprio guia de Users do Supabase diz que os metadados do utilizador não devem ser usados em políticas de RLS nem em lógica de autorização, porque o utilizador pode editá-los sem qualquer verificação.
O que resolveu?
A função passou a vir de uma tabela privada em que o cliente não pode 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;
A função de convite corre no servidor, com a permissão de quem convida verificada. Escreve primeiro o convite pendente e só depois cria a ligação do convite. O trigger procura a função pelo email e apaga a linha. O nome continua a vir dos metadados, o que não faz mal: são dados de apresentação.
A ordem importa. Um convite cria o utilizador de imediato: o guia de Users do Supabase indica que convidar um email novo cria um utilizador não confirmado. O trigger dispara nesse momento, por isso a linha pendente já tem de existir.
Como verificar a sua aplicação?
Abra a sua função handle_new_user, ou qualquer trigger em auth.users, e procure raw_user_meta_data. O nome e o avatar vindos dali não têm problema. Função, empresa, plano, tenant ou qualquer outra coisa que dê acesso tem de vir de uma tabela ou de raw_app_meta_data, que o utilizador não pode alterar.
Depois experimente: registe-se pela API com role_id ou role: 'admin' em options.data. A nova conta não deve receber nada a mais.
Este tipo de verificação é o primeiro passo do meu método de testes de RLS. Para aplicações multi-tenant, veja também o que o RLS não apanha num utilizador em duas orgs. As outras verificações que faço em todas as apps Supabase estão na minha lista de segurança para apps Lovable. A primeira que vejo no dashboard é que schemas a Data API expõe. A Auditoria de segurança Supabase cobre funções, grants, políticas e definições de auth com 45 regras do OWASP Top 10:2025, não o que um trigger copia dos metadados do registo, por isso esta continua a ser uma verificação manual.