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 is to take the role from a table that only the server writes, filled before the invite is sent.
This happened 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 it looked 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.
From the sign-up trigger of a production Supabase app, June 2026.
What fixed it?
The role now comes from a private table the client can't 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, and the inviter's permission is checked there. 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, and that's fine, because it's display data.
The order matters. An invite creates the user straight away, and Supabase's Users guide notes that inviting a new email creates an unconfirmed user. The trigger fires at that moment, so the pending row has to exist already.
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 can't 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 first thing I look at in the dashboard is which schemas the Data API exposes. Automated checkers of roles, grants and policies, including the free one I built, don't see what a trigger copies from sign-up metadata, so this one stays a manual check.
Originally published at madsalt.tech. Written with AI assistance, checked by me.
