Auth and server code
user_metadata vs app_metadata: where to store roles in Supabase
Never store roles, plans or anything that grants access in Supabase’s user_metadata: every user can change their own with one call from the browser. Use app_metadata, which only your server can write, or a roles table that users can’t write, and check that in your policies.
TL;DR
user_metadata(raw_user_meta_data) is set by the user, at sign-up and later withupdateUser. Fine for a name or avatar.app_metadata(raw_app_meta_data) needs the service role key to change. Fine for a role that rarely changes.- A roles table plus a
has_role()helper takes effect immediately and is easier to audit. - Both metadata objects are in the JWT, so a policy can read either: make sure it reads the right one.
The difference in one table
| user_metadata | app_metadata | |
|---|---|---|
| Stored as | auth.users.raw_user_meta_data | auth.users.raw_app_meta_data |
| Who can change it | The user: signUp({ options: { data } }), updateUser({ data }) | Your server, with the service role key: auth.admin.updateUserById |
| In the JWT as | user_metadata | app_metadata |
| Use for | Display name, avatar URL, language | Role, tenant id, anything the user mustn’t choose |
The names are easy to mix up and both objects show up in supabase.auth.getUser() and in the access token, which is exactly why the wrong one gets used.
What “users can edit it” means in practice
Suppose a policy grants admins access like this:
create policy "Admins read all profiles" on public.profiles
for select to authenticated
using ((auth.jwt() -> 'user_metadata' ->> 'role') = 'admin');Any signed-in user can open the browser console on your site and run:
await supabase.auth.updateUser({ data: { role: 'admin' } });
await supabase.auth.refreshSession(); // the new token says role: adminFrom then on the policy treats them as an admin. The same goes for frontend checks like user.user_metadata.role === 'admin' (PL-020), and for a sign-up trigger that copies raw_user_meta_data ->> 'role' into a profile’s role column, where the attacker simply passes options: { data: { role: 'admin' } } when signing up.
Find it in your project
-- Policies that read user_metadata
select schemaname, tablename, policyname, qual, with_check
from pg_policies
where coalesce(qual, '') ilike '%user_metadata%'
or coalesce(with_check, '') ilike '%user_metadata%';
-- Your functions and triggers that read raw_user_meta_data
select n.nspname, p.proname
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname not in ('pg_catalog', 'information_schema', 'auth', 'storage')
and p.prosrc ilike '%raw_user_meta_data%';grep -rnE "user_?metadata" src supabase/functionsCopying a name or avatar from metadata is fine. Look for role, admin, plan, tier, credits, permissions or anything shaped like them. Supabase’s Security Advisor flags the policy form as lint 0015 rls_references_user_metadata.
Option A: app_metadata
Good for a small, stable set of roles. Set it from a server, never from the browser:
import { createClient } from '@supabase/supabase-js';
const admin = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!, // server only
);
const userId = process.argv[2];
const { error } = await admin.auth.admin.updateUserById(userId, {
app_metadata: { role: 'admin' },
});
if (error) throw error;
console.log('role set; it reaches the token on the next refresh');create policy "Admins read all profiles" on public.profiles
for select to authenticated
using ((auth.jwt() -> 'app_metadata' ->> 'role') = 'admin');One trade-off: the role lives in the token, so a change (granting or removing admin) takes effect when the user’s token refreshes, which by default is within an hour. Signing the user out revokes their refresh tokens, but an access token already issued stays valid until it expires, so if you need removal to be instant, use Option B.
Option B: a roles table and has_role()
Better when roles change often or you need an audit trail, and it takes effect on the next query. The table must not be writable by users (that’s PL-013):
create type public.app_role as enum ('admin', 'moderator', 'user');
create table public.user_roles (
user_id uuid not null references auth.users (id) on delete cascade,
role public.app_role not null,
primary key (user_id, role)
);
alter table public.user_roles enable row level security;
-- Users may see their own roles; nobody may write them through the API
create policy "Users read own roles" on public.user_roles
for select to authenticated using ((select auth.uid()) = user_id);
-- A helper for policies. security definer so it can read user_roles
-- without recursion; it only ever answers about the caller.
create or replace function public.has_role(_role public.app_role)
returns boolean
language sql
stable
security definer
set search_path = ''
as $$
select exists (
select 1 from public.user_roles
where user_id = (select auth.uid()) and role = _role
);
$$;
revoke execute on function public.has_role(public.app_role) from public, anon;
grant execute on function public.has_role(public.app_role) to authenticated;
create policy "Admins read all profiles" on public.profiles
for select to authenticated
using ((select public.has_role('admin')));Grant roles from the SQL editor or a server with the service role key: insert into public.user_roles values ('<user id>', 'admin');. In the app, decide what to show with supabase.rpc('has_role', { _role: 'admin' }), and remember that the database rule, not the UI, is the security.
Fixing an app that already uses user_metadata
- Create the roles table (or decide on
app_metadata) and copy over the users who should really be admins, checked by hand. Don’t copy everyone withrole = 'admin'in their metadata: some may have given it to themselves. - Replace every policy, trigger and function that reads a role from
user_metadata. - Change the sign-up trigger to always create the default role.
- Change frontend checks to use the new source.
- Test as a normal user who has set
{ role: 'admin' }in their own metadata: they should get nothing extra.
Sources
- Supabase: user management and metadata
- Supabase: custom claims and role-based access control
- Supabase: database advisors (lint 0015)
Related findings
The checks behind this guide, each with example code, a self-check and the fix.
Access decided by user_metadata, which users can edit
PL-013 · comingUsers can make themselves admin
PL-020Admin check only in the browser
Free check
Check your app for this, free.
Paste your public repo. PatchLabs rebuilds your database from its migrations, tests it as a visitor, a stranger and the owner, reads the code for keys and risky patterns, and hands you the fix.
Written by Manraj Singh Dhillon. Last checked 10 Nov 2026. Code samples are synthetic and run against your own project; nothing here names a real app. Spotted a mistake? Tell us.