In breve
raw_user_meta_data contiene tutto ciò che il browser ha passato a supabase.auth.signUp(). In un’app in produzione il trigger handle_new_user ne leggeva il ruolo del nuovo utente, quindi chiunque poteva registrarsi con l’id del ruolo Owner e diventare proprietario dell’account. La correzione: il ruolo arriva da una tabella in cui scrive solo il server, compilata prima dell’invio dell’invito.
Succedeva nel time tracker della società di revisione, un’app Supabase in cui ogni regola di accesso vive nel database. Il bug stava nel trigger che crea un profilo per ogni nuovo utente.
Cosa faceva il trigger?
In versione semplificata, era così:
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;
$$;
Il flusso di invito metteva lì il role_id giusto, quindi nell’uso normale funzionava. Ma raw_user_meta_data è l’options.data di un signup, e a decidere cosa ci va è il browser:
await supabase.auth.signUp({
email: 'me@example.com',
password: '...',
options: { data: { role_id: '<owner role uuid>' } },
})
Il controllo dei permessi restituiva true per gli owner prima di guardare qualsiasi altra cosa, quindi quel solo campo dava il controllo completo dell’account. La stessa guida Users di Supabase dice che i metadati utente non vanno usati nelle policy RLS né nella logica di autorizzazione, perché l’utente può modificarli senza alcun controllo.
Cosa l’ha risolto?
Ora il ruolo arriva da una tabella privata che il client non può toccare:
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 funzione di invito gira sul server, con il permesso di chi invita già verificato. Scrive prima l’invito in sospeso e solo dopo crea il link di invito. Il trigger cerca il ruolo per email ed elimina la riga. Il nome continua a venire dai metadati, e va bene: sono dati di visualizzazione.
L’ordine conta. Un invito crea subito l’utente: la guida Users di Supabase precisa che invitare una nuova email crea un utente non confermato. Il trigger scatta in quel momento, quindi la riga in sospeso deve già esistere.
Come controllare la sua app?
Apra la sua funzione handle_new_user, o qualsiasi trigger su auth.users, e cerchi raw_user_meta_data. Nome e avatar presi da lì vanno bene. Ruolo, azienda, piano, tenant o qualsiasi altra cosa che dia accesso deve venire da una tabella o da raw_app_meta_data, che l’utente non può modificare.
Poi provi: si registri tramite l’API con role_id o role: 'admin' in options.data. Il nuovo account non deve ottenere nulla in più.
Questo tipo di controllo è il primo passo del mio metodo per testare la RLS. Per le app multi-tenant, veda anche cosa la RLS non vede per un utente in due org. Gli altri controlli che eseguo su ogni app Supabase sono nella mia checklist di sicurezza per le app Lovable. Il primo che guardo nella dashboard è quali schemi espone la Data API. Il Controllo sicurezza Supabase copre ruoli, grant, policy e impostazioni di autenticazione con 45 regole OWASP Top 10:2025, non ciò che un trigger copia dai metadati di registrazione, quindi questo resta un controllo manuale.