To restrict database access by user role in Supabase, store roles either in user JWT claims (app_metadata) or in a dedicated roles table, then reference them in RLS policies. JWT-based roles are fastest because the role is read directly from the token without an extra query. Table-based roles offer more flexibility for complex permission models. Both approaches use auth.jwt() in the RLS policy to check the current user's role before allowing or denying the operation.
Role-Based Access Control with Supabase RLS Policies
Row Level Security is the backbone of Supabase's security model, but basic owner-only policies are not enough for applications with multiple user roles like admin, editor, and viewer. This tutorial shows you how to extend RLS with role-based checks so that different users have different levels of access to the same table. You will learn both the JWT claims approach and the roles table approach, with trade-offs for each.
Prerequisites
- A Supabase project with RLS enabled on target tables
- Users set up with Supabase Auth
- Understanding of basic RLS policies (using auth.uid())
- For JWT approach: ability to set app_metadata via admin API or Edge Function
Step-by-step guide
Choose between JWT claims and a roles table
Choose between JWT claims and a roles table
There are two main patterns for role-based RLS. The JWT claims approach stores the user's role in app_metadata, which is embedded in the JWT token. RLS policies read it with auth.jwt()->'app_metadata'->>'role' — no database query needed, making it the fastest option. The roles table approach stores roles in a separate table and joins against it in policies. This is slower per query but lets you change roles instantly without waiting for token refresh. Choose JWT for simple role models (admin/user) and a roles table for complex multi-role or multi-tenant systems.
1-- Option A: Read role from JWT claims (fast, no query)2-- Set role via admin API: supabase.auth.admin.updateUserById(userId, { app_metadata: { role: 'admin' } })3-- Access in RLS:4(select auth.jwt()->'app_metadata'->>'role') = 'admin'56-- Option B: Read role from a roles table (flexible, queryable)7-- Create a user_roles table and join in policies:8EXISTS (9 SELECT 1 FROM user_roles10 WHERE user_roles.user_id = (select auth.uid())11 AND user_roles.role = 'admin'12)Expected result: You have chosen the approach that fits your application's complexity and latency requirements.
Set user roles via JWT app_metadata
Set user roles via JWT app_metadata
To use the JWT approach, set the user's role in app_metadata using the admin API. This must be done server-side (Edge Function or backend) because app_metadata cannot be set from the client. Once set, the role is included in every JWT the user receives. The auth.jwt() function in SQL policies reads the decoded token.
1// Server-side: Set a user's role using the admin API2// MUST use the service role key — never expose this client-side3import { createClient } from '@supabase/supabase-js'45const supabaseAdmin = createClient(6 process.env.SUPABASE_URL!,7 process.env.SUPABASE_SERVICE_ROLE_KEY!8)910// Assign admin role11const { data, error } = await supabaseAdmin.auth.admin.updateUserById(12 userId,13 { app_metadata: { role: 'admin' } }14)1516// Assign editor role17await supabaseAdmin.auth.admin.updateUserById(18 userId,19 { app_metadata: { role: 'editor' } }20)2122// The JWT now contains: { app_metadata: { role: 'admin' } }23// Accessible in SQL via: auth.jwt()->'app_metadata'->>'role'Expected result: The user's JWT contains the assigned role in app_metadata, readable by RLS policies.
Write RLS policies that check JWT roles
Write RLS policies that check JWT roles
Create policies that read the role from the JWT and grant or deny access accordingly. Admins can typically perform all operations, editors can read and update, and viewers can only read. Use the OR operator to combine role checks with ownership checks for layered permissions.
1-- Table: articles2ALTER TABLE articles ENABLE ROW LEVEL SECURITY;34-- Admins can do everything5CREATE POLICY "Admins have full access" ON articles6 FOR ALL TO authenticated7 USING (8 (select auth.jwt()->'app_metadata'->>'role') = 'admin'9 );1011-- Editors can read all and update their own12CREATE POLICY "Editors can read all articles" ON articles13 FOR SELECT TO authenticated14 USING (15 (select auth.jwt()->'app_metadata'->>'role') IN ('editor', 'admin')16 );1718CREATE POLICY "Editors can update own articles" ON articles19 FOR UPDATE TO authenticated20 USING (21 (select auth.jwt()->'app_metadata'->>'role') = 'editor'22 AND (select auth.uid()) = author_id23 );2425-- Viewers can only read published articles26CREATE POLICY "Viewers read published" ON articles27 FOR SELECT TO authenticated28 USING (29 status = 'published'30 OR (select auth.jwt()->'app_metadata'->>'role') IN ('editor', 'admin')31 );Expected result: Admins can perform all CRUD operations, editors can read all and edit their own articles, and viewers can only see published articles.
Create a roles table for flexible role management
Create a roles table for flexible role management
For complex permission models, create a user_roles table that maps users to roles. This approach supports multiple roles per user, time-limited roles, and tenant-scoped roles. RLS policies use EXISTS subqueries to check role membership. Add an index on user_id for fast lookups since this query runs on every request.
1-- Create the roles table2CREATE TABLE 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-- RLS on the roles table itself (only admins can manage)14ALTER TABLE user_roles ENABLE ROW LEVEL SECURITY;1516CREATE POLICY "Users can read own roles" ON user_roles17 FOR SELECT TO authenticated18 USING ((select auth.uid()) = user_id);1920CREATE POLICY "Admins can manage all roles" ON user_roles21 FOR ALL TO authenticated22 USING (23 EXISTS (24 SELECT 1 FROM user_roles25 WHERE user_id = (select auth.uid()) AND role = 'admin'26 )27 );2829-- Use in other table policies30CREATE POLICY "Editors can update articles" ON articles31 FOR UPDATE TO authenticated32 USING (33 EXISTS (34 SELECT 1 FROM user_roles35 WHERE user_id = (select auth.uid())36 AND role IN ('editor', 'admin')37 )38 );Expected result: Role assignments are stored in the database and checked by RLS policies on every request.
Test role-based policies with different users
Test role-based policies with different users
Verify your policies work by testing with users assigned different roles. Sign in as each role and attempt operations that should be allowed and denied. In the SQL Editor, you can also test policies directly by setting the role and JWT claims for the session.
1-- Test as an admin (in SQL Editor)2SET request.jwt.claims = '{"sub": "admin-uuid", "role": "authenticated", "app_metadata": {"role": "admin"}}';3SET role = 'authenticated';4SELECT * FROM articles; -- Should return all5INSERT INTO articles (title, author_id) VALUES ('Test', 'admin-uuid'); -- Should work67-- Test as a viewer8SET request.jwt.claims = '{"sub": "viewer-uuid", "role": "authenticated", "app_metadata": {"role": "viewer"}}';9SET role = 'authenticated';10SELECT * FROM articles; -- Should return only published11INSERT INTO articles (title, author_id) VALUES ('Test', 'viewer-uuid'); -- Should fail1213-- Reset14RESET role;15RESET request.jwt.claims;Expected result: Each role sees only the data and operations permitted by the RLS policies. Unauthorized operations return empty results or errors.
Complete working example
1-- ================================================2-- Role-Based Access Control with Supabase RLS3-- ================================================45-- 1. Create the articles table6CREATE TABLE articles (7 id uuid PRIMARY KEY DEFAULT gen_random_uuid(),8 title text NOT NULL,9 content text DEFAULT '',10 status text DEFAULT 'draft' CHECK (status IN ('draft', 'published', 'archived')),11 author_id uuid REFERENCES auth.users(id) ON DELETE CASCADE NOT NULL,12 created_at timestamptz DEFAULT now(),13 updated_at timestamptz DEFAULT now()14);1516ALTER TABLE articles ENABLE ROW LEVEL SECURITY;1718-- 2. Create the user_roles table19CREATE TABLE user_roles (20 id uuid PRIMARY KEY DEFAULT gen_random_uuid(),21 user_id uuid REFERENCES auth.users(id) ON DELETE CASCADE NOT NULL,22 role text NOT NULL CHECK (role IN ('admin', 'editor', 'viewer')),23 created_at timestamptz DEFAULT now(),24 UNIQUE(user_id, role)25);2627CREATE INDEX idx_user_roles_lookup ON user_roles (user_id, role);28ALTER TABLE user_roles ENABLE ROW LEVEL SECURITY;2930-- 3. Helper function for role checking31CREATE OR REPLACE FUNCTION public.has_role(required_role text)32RETURNS boolean33LANGUAGE sql34SECURITY DEFINER35SET search_path = ''36AS $$37 SELECT EXISTS (38 SELECT 1 FROM public.user_roles39 WHERE user_id = (select auth.uid())40 AND role = required_role41 );42$$;4344-- 4. RLS policies for articles45CREATE POLICY "Admins: full access" ON articles FOR ALL TO authenticated46 USING (public.has_role('admin'));4748CREATE POLICY "Editors: read all" ON articles FOR SELECT TO authenticated49 USING (public.has_role('editor') OR public.has_role('admin'));5051CREATE POLICY "Editors: insert own" ON articles FOR INSERT TO authenticated52 WITH CHECK (53 (public.has_role('editor') OR public.has_role('admin'))54 AND (select auth.uid()) = author_id55 );5657CREATE POLICY "Editors: update own" ON articles FOR UPDATE TO authenticated58 USING (public.has_role('editor') AND (select auth.uid()) = author_id)59 WITH CHECK (public.has_role('editor') AND (select auth.uid()) = author_id);6061CREATE POLICY "Viewers: read published" ON articles FOR SELECT TO authenticated62 USING (status = 'published');6364-- 5. RLS policies for user_roles65CREATE POLICY "Read own roles" ON user_roles FOR SELECT TO authenticated66 USING ((select auth.uid()) = user_id);6768CREATE POLICY "Admins manage roles" ON user_roles FOR ALL TO authenticated69 USING (public.has_role('admin'));Common mistakes when restricting Access by Role in Supabase RLS
Why it's a problem: Setting roles in raw_user_meta_data instead of app_metadata, allowing users to change their own role
How to avoid: Always store roles in app_metadata, which can only be modified server-side with the service role key. raw_user_meta_data is writable by the user via updateUser().
Why it's a problem: Not handling the case where a user has no role assigned
How to avoid: Add a default policy for users with no role that grants minimal access (e.g., read published content only). Test with a user who has NULL app_metadata to verify.
Why it's a problem: Calling auth.jwt() without wrapping in a SELECT subquery, causing per-row evaluation
How to avoid: Use (select auth.jwt()->'app_metadata'->>'role') instead of auth.jwt()->'app_metadata'->>'role'. The select wrapper caches the value for the entire statement.
Why it's a problem: Expecting JWT role changes to take effect immediately
How to avoid: JWT claims update only on token refresh (default: every hour). Either force a token refresh after role changes, or use a roles table for instant effect.
Best practices
- Use app_metadata for roles (not raw_user_meta_data) because users cannot modify it from the client
- Wrap auth.jwt() and auth.uid() in SELECT subqueries for per-statement caching in RLS policies
- Create a helper function like has_role() to keep RLS policies readable and DRY
- Add indexes on the user_id column in roles tables since RLS checks run on every query
- Test policies with each role to verify both allowed and denied operations work correctly
- Use SECURITY DEFINER on helper functions that query the roles table, with search_path = '' for safety
- Default to the most restrictive access when no role is assigned — deny everything unless explicitly permitted
- Document your role hierarchy and which operations each role can perform on each table
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
I have a Supabase project with admin, editor, and viewer roles. Show me how to write RLS policies on an articles table where admins have full access, editors can read all and edit their own, and viewers can only read published articles. Include both JWT claims and roles table approaches.
Create a complete role-based access control system in Supabase SQL: a user_roles table with RLS, a has_role() helper function, and RLS policies on an articles table that restrict CRUD operations by admin, editor, and viewer roles. Include indexes for performance.
Frequently asked questions
Should I use JWT claims or a roles table for role-based access?
Use JWT claims for simple role models (admin vs user) where role changes are infrequent. Use a roles table for complex permission models with multiple roles per user, tenant-scoped roles, or when role changes need to take effect immediately without waiting for token refresh.
Can a user have multiple roles?
With the roles table approach, yes — each user can have multiple rows in user_roles. With JWT claims, store roles as an array in app_metadata and check with @> containment in policies. The roles table approach is more flexible for multi-role scenarios.
How do I promote a user to admin safely?
Call supabase.auth.admin.updateUserById(userId, { app_metadata: { role: 'admin' } }) from a server-side function using the service role key. Always verify that the requesting user is already an admin before allowing role changes.
What happens if RLS policies conflict for the same role?
PostgreSQL evaluates RLS policies with OR logic for the same operation. If any policy allows the action, it proceeds. This means a viewer SELECT policy and an admin SELECT policy both apply — the admin sees everything because their policy returns true for all rows.
Do role checks in RLS affect query performance?
JWT-based role checks are essentially free — they read from the decoded token in memory. Roles table checks add a subquery per statement (not per row if you use a helper function with SELECT caching). Add an index on user_id to keep the lookup fast.
Can RapidDev help design a role-based access control system for my Supabase project?
Yes. RapidDev can design and implement a complete RBAC system including role hierarchies, RLS policies, admin interfaces for managing roles, and thorough testing across all permission levels.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation