TL;DR
raw_user_meta_data holds whatever the browser passed to supabase.auth.signUp(). On one production app the handle_new_user trigger read the new user’s role from it, so anyone could sign up with the Owner role id and own the account. The fix: the role comes from a table only the server writes, filled before the invite is sent.
This was on the audit firm time tracker, a Supabase app where every access rule lives in the database. The bug sat in the trigger that creates a profile for each new user.
What did the trigger do?
Simplified, it looked like this:
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;
$$;
The invite flow put the right role_id there, so in normal use it worked. But raw_user_meta_data is the options.data of a sign-up, and the browser decides what goes in it:
await supabase.auth.signUp({
email: 'me@example.com',
password: '...',
options: { data: { role_id: '<owner role uuid>' } },
})
The permission check returned true for owners before looking at anything else, so that one field gave full control of the account. Supabase’s own Users guide says user metadata must not be used in RLS policies or authorization logic, because the user can edit it without any checks.
What fixed it?
The role now comes from a private table the client cannot touch:
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;
The invite function runs on the server with the inviter’s permission checked. It writes the pending invite first and only then creates the invite link. The trigger looks the role up by email and deletes the row. The name still comes from metadata, which is fine: it is display data.
The order matters. An invite creates the user straight away: Supabase’s Users guide notes that inviting a new email creates an unconfirmed user. The trigger fires at that moment, so the pending row must already exist.
How do you check your own app?
Open your handle_new_user function, or any trigger on auth.users, and search for raw_user_meta_data. Name and avatar from there are fine. Role, company, plan, tenant or anything else that grants access must come from a table or from raw_app_meta_data, which the user cannot change.
Then try it: sign up through the API with role_id or role: 'admin' in options.data. The new account should get nothing extra.
This kind of check is the first step of my RLS test method. For multi-tenant apps, also see what RLS misses for a user in two orgs. The other checks I run on every Supabase app are in my Lovable app security checklist. The first one I look at in the dashboard is which schemas the Data API exposes. The Supabase security check covers roles, grants, policies and auth settings against 45 OWASP Top 10:2025 rules, not what a trigger copies from sign-up metadata, so this one stays a manual check.