Custom user roles in Supabase are stored in one of two places: the user's app_metadata field in the JWT token, or a dedicated roles table in your database. To set a role via app_metadata, call supabase.auth.admin.updateUserById() from a server-side function with the service role key. The role then appears in every JWT, accessible via auth.jwt() in RLS policies. For more flexible role management, create a user_roles table and query it in your policies.
Setting Up Custom User Roles in Supabase
Most applications need more than just 'logged in' and 'not logged in'. You might need admin, editor, viewer, or custom roles that control what each user can see and do. Supabase does not have a built-in roles UI, but PostgreSQL and the auth system give you everything needed to implement roles. This tutorial walks through setting up roles, assigning them to users, and reading them in your application and RLS policies.
Prerequisites
- A Supabase project with Authentication configured
- At least one test user created
- Basic understanding of Supabase RLS and auth.uid()
Step-by-step guide
Set a role in app_metadata using the admin API
Set a role in app_metadata using the admin API
The fastest way to add roles is by storing them in the user's app_metadata. This field is embedded in the JWT token and cannot be modified by the user from the client. Use the admin API from a server-side environment (Edge Function, API route, or script) with the service role key. Once set, the role is available in every authenticated request via auth.jwt().
1import { createClient } from '@supabase/supabase-js'23// Server-side only — NEVER use service role key in client code4const supabaseAdmin = createClient(5 process.env.SUPABASE_URL!,6 process.env.SUPABASE_SERVICE_ROLE_KEY!7)89// Set a user's role to 'admin'10const { data, error } = await supabaseAdmin.auth.admin.updateUserById(11 'user-uuid-here',12 {13 app_metadata: { role: 'admin' }14 }15)1617// Set a user's role to 'editor'18await supabaseAdmin.auth.admin.updateUserById(19 'another-user-uuid',20 {21 app_metadata: { role: 'editor' }22 }23)2425console.log('Role updated:', data.user.app_metadata)Expected result: The user's app_metadata now contains { role: 'admin' } and it will appear in their next JWT token.
Assign a default role on signup with a trigger
Assign a default role on signup with a trigger
Instead of manually assigning roles to every new user, create a database trigger that automatically sets a default role when a user signs up. This trigger fires after every insert on auth.users and updates app_metadata with the default role. Admins can later upgrade users to higher roles using the admin API.
1-- Function to set default role on signup2CREATE OR REPLACE FUNCTION public.set_default_role()3RETURNS trigger4LANGUAGE plpgsql5SECURITY DEFINER6SET search_path = ''7AS $$8BEGIN9 -- Only set if no role exists yet10 IF NOT (NEW.raw_app_meta_data ? 'role') THEN11 NEW.raw_app_meta_data = 12 COALESCE(NEW.raw_app_meta_data, '{}'::jsonb) || '{"role": "viewer"}'::jsonb;13 END IF;14 RETURN NEW;15END;16$$;1718-- Trigger on auth.users insert19CREATE TRIGGER on_auth_user_created_set_role20 BEFORE INSERT ON auth.users21 FOR EACH ROW22 EXECUTE FUNCTION public.set_default_role();Expected result: Every new user automatically gets { role: 'viewer' } in their app_metadata without any extra API calls.
Read roles in the client application
Read roles in the client application
On the client side, read the user's role from the session to control UI visibility (hide admin buttons from viewers, show edit controls only to editors). Use getUser() or getSession() to access the app_metadata. Remember that client-side role checks are for UI convenience only — the real security enforcement happens in RLS policies.
1import { createClient } from '@supabase/supabase-js'23const supabase = createClient(4 process.env.NEXT_PUBLIC_SUPABASE_URL!,5 process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!6)78// Read the user's role9const { data: { user } } = await supabase.auth.getUser()10const role = user?.app_metadata?.role ?? 'viewer'1112console.log('User role:', role) // 'admin', 'editor', or 'viewer'1314// Use in React for UI visibility15function AdminPanel() {16 const [role, setRole] = useState<string>('viewer')1718 useEffect(() => {19 supabase.auth.getUser().then(({ data: { user } }) => {20 setRole(user?.app_metadata?.role ?? 'viewer')21 })22 }, [])2324 if (role !== 'admin') return null25 return <div>Admin Panel Content</div>26}Expected result: The client application can read the user's role and conditionally render UI elements based on it.
Read roles in RLS policies with auth.jwt()
Read roles in RLS policies with auth.jwt()
The auth.jwt() function returns the decoded JWT token in SQL, giving you access to app_metadata and the role stored there. Use it in RLS policies to restrict data access by role. This is where the real security enforcement happens — even if someone bypasses your UI, the database itself enforces the role check.
1-- Read the role from JWT app_metadata in an RLS policy2CREATE POLICY "Only admins can delete" ON articles3 FOR DELETE TO authenticated4 USING (5 (select auth.jwt()->'app_metadata'->>'role') = 'admin'6 );78-- Allow multiple roles9CREATE POLICY "Editors and admins can update" ON articles10 FOR UPDATE TO authenticated11 USING (12 (select auth.jwt()->'app_metadata'->>'role') IN ('editor', 'admin')13 )14 WITH CHECK (15 (select auth.jwt()->'app_metadata'->>'role') IN ('editor', 'admin')16 );1718-- Everyone can read, but only published for non-admins19CREATE POLICY "Read access by role" ON articles20 FOR SELECT TO authenticated21 USING (22 status = 'published'23 OR (select auth.jwt()->'app_metadata'->>'role') IN ('editor', 'admin')24 );Expected result: RLS policies enforce role-based access at the database level, regardless of what the client sends.
Create a roles table for advanced role management
Create a roles table for advanced role management
For applications that need multiple roles per user, role hierarchy, or instant role changes, create a dedicated user_roles table. This gives you queryable, auditable role data and avoids the JWT refresh delay. The trade-off is a small performance cost from the extra subquery in RLS policies.
1-- Create user_roles table2CREATE TABLE public.user_roles (3 id uuid PRIMARY KEY DEFAULT gen_random_uuid(),4 user_id uuid REFERENCES auth.users(id) ON DELETE CASCADE NOT NULL,5 role text NOT NULL CHECK (role IN ('admin', 'editor', 'viewer')),6 created_at timestamptz DEFAULT now(),7 UNIQUE(user_id, role)8);910-- Index for fast lookups in RLS policies11CREATE INDEX idx_user_roles_user_id ON user_roles (user_id);1213-- Enable RLS on the roles table itself14ALTER TABLE user_roles ENABLE ROW LEVEL SECURITY;1516-- Users can read their own roles17CREATE POLICY "Read own roles" ON user_roles18 FOR SELECT TO authenticated19 USING ((select auth.uid()) = user_id);2021-- Insert a role22INSERT INTO user_roles (user_id, role)23VALUES ('user-uuid', 'editor');Expected result: A user_roles table that stores role assignments, queryable from RLS policies and application code.
Complete working example
1-- ================================================2-- Custom User Roles Setup for Supabase3-- ================================================45-- 1. Auto-assign default role on signup6CREATE OR REPLACE FUNCTION public.set_default_role()7RETURNS trigger8LANGUAGE plpgsql9SECURITY DEFINER10SET search_path = ''11AS $$12BEGIN13 IF NOT (NEW.raw_app_meta_data ? 'role') THEN14 NEW.raw_app_meta_data =15 COALESCE(NEW.raw_app_meta_data, '{}'::jsonb)16 || '{"role": "viewer"}'::jsonb;17 END IF;18 RETURN NEW;19END;20$$;2122CREATE TRIGGER on_auth_user_created_set_role23 BEFORE INSERT ON auth.users24 FOR EACH ROW25 EXECUTE FUNCTION public.set_default_role();2627-- 2. Optional: user_roles table for advanced role management28CREATE TABLE public.user_roles (29 id uuid PRIMARY KEY DEFAULT gen_random_uuid(),30 user_id uuid REFERENCES auth.users(id) ON DELETE CASCADE NOT NULL,31 role text NOT NULL CHECK (role IN ('admin', 'editor', 'viewer')),32 created_at timestamptz DEFAULT now(),33 UNIQUE(user_id, role)34);3536CREATE INDEX idx_user_roles_lookup ON user_roles (user_id, role);37ALTER TABLE user_roles ENABLE ROW LEVEL SECURITY;3839CREATE POLICY "Read own roles" ON user_roles40 FOR SELECT TO authenticated41 USING ((select auth.uid()) = user_id);4243-- 3. Helper function to check roles from JWT44CREATE OR REPLACE FUNCTION public.get_my_role()45RETURNS text46LANGUAGE sql47STABLE48AS $$49 SELECT COALESCE(50 auth.jwt()->'app_metadata'->>'role',51 'viewer'52 );53$$;5455-- 4. Example RLS policies using JWT roles56-- Create sample table57CREATE TABLE articles (58 id uuid PRIMARY KEY DEFAULT gen_random_uuid(),59 title text NOT NULL,60 status text DEFAULT 'draft',61 author_id uuid REFERENCES auth.users(id) NOT NULL,62 created_at timestamptz DEFAULT now()63);6465ALTER TABLE articles ENABLE ROW LEVEL SECURITY;6667CREATE POLICY "Admin full access" ON articles FOR ALL68 TO authenticated69 USING ((select public.get_my_role()) = 'admin');7071CREATE POLICY "Editor read all" ON articles FOR SELECT72 TO authenticated73 USING ((select public.get_my_role()) IN ('editor', 'admin'));7475CREATE POLICY "Editor insert own" ON articles FOR INSERT76 TO authenticated77 WITH CHECK (78 (select public.get_my_role()) IN ('editor', 'admin')79 AND (select auth.uid()) = author_id80 );8182CREATE POLICY "Viewer read published" ON articles FOR SELECT83 TO authenticated84 USING (status = 'published');Common mistakes when setting Custom User Roles in Supabase
Why it's a problem: Storing roles in raw_user_meta_data instead of app_metadata
How to avoid: Use app_metadata for roles. raw_user_meta_data is writable by the user via supabase.auth.updateUser(), so any user could change their own role.
Why it's a problem: Trying to set app_metadata from client-side code
How to avoid: app_metadata can only be set using the admin API with the service role key. Create an Edge Function or server endpoint for role assignment.
Why it's a problem: Relying on client-side role checks for security without RLS policies
How to avoid: Client-side checks (hiding buttons, redirecting) are for UX only. Always enforce roles in RLS policies at the database level where they cannot be bypassed.
Best practices
- Store roles in app_metadata for simple role models — it is embedded in the JWT with zero query overhead
- Use a database trigger to auto-assign a default role on signup so no user is ever without a role
- Always wrap auth.jwt() in a SELECT subquery in RLS policies for per-statement caching
- Create an Edge Function for role management that verifies the requester is an admin before changing roles
- Use a roles table when you need multiple roles per user, instant role changes, or audit history
- Never expose the service role key in client-side code — it bypasses all RLS
- Default to the most restrictive role (viewer) when no role is assigned
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
I want to add admin, editor, and viewer roles to my Supabase project. Show me how to store roles in app_metadata, assign a default role on signup with a trigger, and read the role in RLS policies using auth.jwt().
Create a complete role system for Supabase: a trigger to set a default role on signup in app_metadata, an Edge Function to promote users to admin, and RLS policies on an articles table that restrict access by role.
Frequently asked questions
Where are roles stored in Supabase?
Supabase does not have a built-in roles system. You store roles either in the user's app_metadata field (embedded in the JWT token) or in a custom database table. Both approaches work with RLS policies.
Can I change a user's role from the Dashboard?
Yes. Go to Authentication → Users, find the user, and edit their app_metadata JSON to add or change the role field. The change takes effect on the next token refresh.
How long does it take for a role change to take effect?
With app_metadata, the change is embedded in the JWT which refreshes every hour by default. The user gets the new role on next token refresh. With a roles table, the change is immediate because the database is queried directly.
Can a user have multiple roles simultaneously?
With a roles table, yes — create multiple rows for the same user. With app_metadata, store an array: { roles: ['admin', 'editor'] } and check with a containment operator in policies.
What is the difference between Supabase's anon/authenticated roles and custom roles?
The anon and authenticated roles are PostgreSQL roles that Supabase assigns based on the API key used. Custom roles (admin, editor, viewer) are application-level roles you define and enforce through RLS policies. They work together — custom role checks only apply to authenticated users.
Can RapidDev help set up a role-based access system in Supabase?
Yes. RapidDev can design and implement a custom role system including role assignment flows, RLS policies per table, admin interfaces for role management, and automated testing.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation