# How to Set Custom User Roles in Supabase

- Tool: Supabase
- Difficulty: Beginner
- Time required: 10-15 min
- Compatibility: Supabase (all plans), @supabase/supabase-js v2+
- Last updated: March 2026

## TL;DR

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.

## Before you start

- A Supabase project with Authentication configured
- At least one test user created
- Basic understanding of Supabase RLS and auth.uid()

## Step-by-step guide

### 1. 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().

```
import { createClient } from '@supabase/supabase-js'

// Server-side only — NEVER use service role key in client code
const supabaseAdmin = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

// Set a user's role to 'admin'
const { data, error } = await supabaseAdmin.auth.admin.updateUserById(
  'user-uuid-here',
  {
    app_metadata: { role: 'admin' }
  }
)

// Set a user's role to 'editor'
await supabaseAdmin.auth.admin.updateUserById(
  'another-user-uuid',
  {
    app_metadata: { role: 'editor' }
  }
)

console.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.

### 2. 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.

```
-- Function to set default role on signup
CREATE OR REPLACE FUNCTION public.set_default_role()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
BEGIN
  -- Only set if no role exists yet
  IF NOT (NEW.raw_app_meta_data ? 'role') THEN
    NEW.raw_app_meta_data = 
      COALESCE(NEW.raw_app_meta_data, '{}'::jsonb) || '{"role": "viewer"}'::jsonb;
  END IF;
  RETURN NEW;
END;
$$;

-- Trigger on auth.users insert
CREATE TRIGGER on_auth_user_created_set_role
  BEFORE INSERT ON auth.users
  FOR EACH ROW
  EXECUTE FUNCTION public.set_default_role();
```

**Expected result:** Every new user automatically gets { role: 'viewer' } in their app_metadata without any extra API calls.

### 3. 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.

```
import { createClient } from '@supabase/supabase-js'

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)

// Read the user's role
const { data: { user } } = await supabase.auth.getUser()
const role = user?.app_metadata?.role ?? 'viewer'

console.log('User role:', role) // 'admin', 'editor', or 'viewer'

// Use in React for UI visibility
function AdminPanel() {
  const [role, setRole] = useState<string>('viewer')

  useEffect(() => {
    supabase.auth.getUser().then(({ data: { user } }) => {
      setRole(user?.app_metadata?.role ?? 'viewer')
    })
  }, [])

  if (role !== 'admin') return null
  return <div>Admin Panel Content</div>
}
```

**Expected result:** The client application can read the user's role and conditionally render UI elements based on it.

### 4. 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.

```
-- Read the role from JWT app_metadata in an RLS policy
CREATE POLICY "Only admins can delete" ON articles
  FOR DELETE TO authenticated
  USING (
    (select auth.jwt()->'app_metadata'->>'role') = 'admin'
  );

-- Allow multiple roles
CREATE POLICY "Editors and admins can update" ON articles
  FOR UPDATE TO authenticated
  USING (
    (select auth.jwt()->'app_metadata'->>'role') IN ('editor', 'admin')
  )
  WITH CHECK (
    (select auth.jwt()->'app_metadata'->>'role') IN ('editor', 'admin')
  );

-- Everyone can read, but only published for non-admins
CREATE POLICY "Read access by role" ON articles
  FOR SELECT TO authenticated
  USING (
    status = 'published'
    OR (select auth.jwt()->'app_metadata'->>'role') IN ('editor', 'admin')
  );
```

**Expected result:** RLS policies enforce role-based access at the database level, regardless of what the client sends.

### 5. 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.

```
-- Create user_roles table
CREATE TABLE public.user_roles (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id uuid REFERENCES auth.users(id) ON DELETE CASCADE NOT NULL,
  role text NOT NULL CHECK (role IN ('admin', 'editor', 'viewer')),
  created_at timestamptz DEFAULT now(),
  UNIQUE(user_id, role)
);

-- Index for fast lookups in RLS policies
CREATE INDEX idx_user_roles_user_id ON user_roles (user_id);

-- Enable RLS on the roles table itself
ALTER TABLE user_roles ENABLE ROW LEVEL SECURITY;

-- Users can read their own roles
CREATE POLICY "Read own roles" ON user_roles
  FOR SELECT TO authenticated
  USING ((select auth.uid()) = user_id);

-- Insert a role
INSERT INTO user_roles (user_id, role)
VALUES ('user-uuid', 'editor');
```

**Expected result:** A user_roles table that stores role assignments, queryable from RLS policies and application code.

## Complete code example

File: `setup-user-roles.sql`

```sql
-- ================================================
-- Custom User Roles Setup for Supabase
-- ================================================

-- 1. Auto-assign default role on signup
CREATE OR REPLACE FUNCTION public.set_default_role()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
BEGIN
  IF NOT (NEW.raw_app_meta_data ? 'role') THEN
    NEW.raw_app_meta_data =
      COALESCE(NEW.raw_app_meta_data, '{}'::jsonb)
      || '{"role": "viewer"}'::jsonb;
  END IF;
  RETURN NEW;
END;
$$;

CREATE TRIGGER on_auth_user_created_set_role
  BEFORE INSERT ON auth.users
  FOR EACH ROW
  EXECUTE FUNCTION public.set_default_role();

-- 2. Optional: user_roles table for advanced role management
CREATE TABLE public.user_roles (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id uuid REFERENCES auth.users(id) ON DELETE CASCADE NOT NULL,
  role text NOT NULL CHECK (role IN ('admin', 'editor', 'viewer')),
  created_at timestamptz DEFAULT now(),
  UNIQUE(user_id, role)
);

CREATE INDEX idx_user_roles_lookup ON user_roles (user_id, role);
ALTER TABLE user_roles ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Read own roles" ON user_roles
  FOR SELECT TO authenticated
  USING ((select auth.uid()) = user_id);

-- 3. Helper function to check roles from JWT
CREATE OR REPLACE FUNCTION public.get_my_role()
RETURNS text
LANGUAGE sql
STABLE
AS $$
  SELECT COALESCE(
    auth.jwt()->'app_metadata'->>'role',
    'viewer'
  );
$$;

-- 4. Example RLS policies using JWT roles
-- Create sample table
CREATE TABLE articles (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  title text NOT NULL,
  status text DEFAULT 'draft',
  author_id uuid REFERENCES auth.users(id) NOT NULL,
  created_at timestamptz DEFAULT now()
);

ALTER TABLE articles ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Admin full access" ON articles FOR ALL
  TO authenticated
  USING ((select public.get_my_role()) = 'admin');

CREATE POLICY "Editor read all" ON articles FOR SELECT
  TO authenticated
  USING ((select public.get_my_role()) IN ('editor', 'admin'));

CREATE POLICY "Editor insert own" ON articles FOR INSERT
  TO authenticated
  WITH CHECK (
    (select public.get_my_role()) IN ('editor', 'admin')
    AND (select auth.uid()) = author_id
  );

CREATE POLICY "Viewer read published" ON articles FOR SELECT
  TO authenticated
  USING (status = 'published');
```

## Common mistakes

- **Storing roles in raw_user_meta_data instead of app_metadata** — undefined Fix: 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.
- **Trying to set app_metadata from client-side code** — undefined Fix: 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.
- **Relying on client-side role checks for security without RLS policies** — undefined Fix: 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

## 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.

---

Source: https://www.rapidevelopers.com/supabase-tutorial/how-to-set-custom-user-roles-in-supabase
© RapidDev — https://www.rapidevelopers.com/supabase-tutorial/how-to-set-custom-user-roles-in-supabase
