The metadata trap: how users promote themselves to admin.
Supabase attaches two JSON blobs to every user. app_metadata is written
only by privileged server-side calls. user_metadata is editable by the
logged-in user through the standard update call in supabase-js. That distinction is
the whole game: AI builders routinely store role: "admin" in
user_metadata because it is the field the client SDK writes naturally, and any user
who opens devtools can then update their own metadata and walk through every
role check built on it.
Store roles in app_metadata via a server-side call, or in a table you control, and
reference them from RLS:
-- roles in a table the user cannot write
create table public.user_roles (
user_id uuid primary key references auth.users (id) on delete cascade,
role text not null default 'member'
);
alter table public.user_roles enable row level security;
-- no insert/update policies for regular users: server-side only
create policy "Admins can read all orders"
on public.orders
for select
to authenticated
using (
exists (
select 1 from public.user_roles r
where r.user_id = (select auth.uid())
and r.role = 'admin'
)
);
Then audit for the trap: search your codebase for user_metadata and
confirm nothing security-relevant is read from it. The check takes one grep and has
paid for itself in every review where we ran it.
Authenticated is a crowd, not a permission
Every RLS policy and every rule written as "any authenticated user may..." should be
read as "any stranger with a free account may...". Sign-up is public. Authentication
establishes identity; authorisation is a separate decision you still have to make.