Skip to main content
RapidDev - Software Development Agency
supabase-tutorial

How to Restrict Access by Role in Supabase RLS

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.

What you'll learn

  • How to write RLS policies that check user roles from JWT claims
  • How to build a roles table for flexible role-based access control
  • How to restrict SELECT, INSERT, UPDATE, and DELETE by role
  • How to combine role checks with row ownership for fine-grained permissions
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Advanced10 min read20-25 minSupabase (all plans), @supabase/supabase-js v2+, PostgreSQL 14+March 2026RapidDev Engineering Team
TL;DR

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

1

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.

typescript
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'
5
6-- 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_roles
10 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.

2

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.

typescript
1// Server-side: Set a user's role using the admin API
2// MUST use the service role key — never expose this client-side
3import { createClient } from '@supabase/supabase-js'
4
5const supabaseAdmin = createClient(
6 process.env.SUPABASE_URL!,
7 process.env.SUPABASE_SERVICE_ROLE_KEY!
8)
9
10// Assign admin role
11const { data, error } = await supabaseAdmin.auth.admin.updateUserById(
12 userId,
13 { app_metadata: { role: 'admin' } }
14)
15
16// Assign editor role
17await supabaseAdmin.auth.admin.updateUserById(
18 userId,
19 { app_metadata: { role: 'editor' } }
20)
21
22// 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.

3

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.

typescript
1-- Table: articles
2ALTER TABLE articles ENABLE ROW LEVEL SECURITY;
3
4-- Admins can do everything
5CREATE POLICY "Admins have full access" ON articles
6 FOR ALL TO authenticated
7 USING (
8 (select auth.jwt()->'app_metadata'->>'role') = 'admin'
9 );
10
11-- Editors can read all and update their own
12CREATE POLICY "Editors can read all articles" ON articles
13 FOR SELECT TO authenticated
14 USING (
15 (select auth.jwt()->'app_metadata'->>'role') IN ('editor', 'admin')
16 );
17
18CREATE POLICY "Editors can update own articles" ON articles
19 FOR UPDATE TO authenticated
20 USING (
21 (select auth.jwt()->'app_metadata'->>'role') = 'editor'
22 AND (select auth.uid()) = author_id
23 );
24
25-- Viewers can only read published articles
26CREATE POLICY "Viewers read published" ON articles
27 FOR SELECT TO authenticated
28 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.

4

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.

typescript
1-- Create the roles table
2CREATE 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);
9
10-- Index for fast lookups in RLS policies
11CREATE INDEX idx_user_roles_user_id ON user_roles (user_id);
12
13-- RLS on the roles table itself (only admins can manage)
14ALTER TABLE user_roles ENABLE ROW LEVEL SECURITY;
15
16CREATE POLICY "Users can read own roles" ON user_roles
17 FOR SELECT TO authenticated
18 USING ((select auth.uid()) = user_id);
19
20CREATE POLICY "Admins can manage all roles" ON user_roles
21 FOR ALL TO authenticated
22 USING (
23 EXISTS (
24 SELECT 1 FROM user_roles
25 WHERE user_id = (select auth.uid()) AND role = 'admin'
26 )
27 );
28
29-- Use in other table policies
30CREATE POLICY "Editors can update articles" ON articles
31 FOR UPDATE TO authenticated
32 USING (
33 EXISTS (
34 SELECT 1 FROM user_roles
35 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.

5

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.

typescript
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 all
5INSERT INTO articles (title, author_id) VALUES ('Test', 'admin-uuid'); -- Should work
6
7-- Test as a viewer
8SET request.jwt.claims = '{"sub": "viewer-uuid", "role": "authenticated", "app_metadata": {"role": "viewer"}}';
9SET role = 'authenticated';
10SELECT * FROM articles; -- Should return only published
11INSERT INTO articles (title, author_id) VALUES ('Test', 'viewer-uuid'); -- Should fail
12
13-- Reset
14RESET 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

role-based-rls.sql
1-- ================================================
2-- Role-Based Access Control with Supabase RLS
3-- ================================================
4
5-- 1. Create the articles table
6CREATE 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);
15
16ALTER TABLE articles ENABLE ROW LEVEL SECURITY;
17
18-- 2. Create the user_roles table
19CREATE 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);
26
27CREATE INDEX idx_user_roles_lookup ON user_roles (user_id, role);
28ALTER TABLE user_roles ENABLE ROW LEVEL SECURITY;
29
30-- 3. Helper function for role checking
31CREATE OR REPLACE FUNCTION public.has_role(required_role text)
32RETURNS boolean
33LANGUAGE sql
34SECURITY DEFINER
35SET search_path = ''
36AS $$
37 SELECT EXISTS (
38 SELECT 1 FROM public.user_roles
39 WHERE user_id = (select auth.uid())
40 AND role = required_role
41 );
42$$;
43
44-- 4. RLS policies for articles
45CREATE POLICY "Admins: full access" ON articles FOR ALL TO authenticated
46 USING (public.has_role('admin'));
47
48CREATE POLICY "Editors: read all" ON articles FOR SELECT TO authenticated
49 USING (public.has_role('editor') OR public.has_role('admin'));
50
51CREATE POLICY "Editors: insert own" ON articles FOR INSERT TO authenticated
52 WITH CHECK (
53 (public.has_role('editor') OR public.has_role('admin'))
54 AND (select auth.uid()) = author_id
55 );
56
57CREATE POLICY "Editors: update own" ON articles FOR UPDATE TO authenticated
58 USING (public.has_role('editor') AND (select auth.uid()) = author_id)
59 WITH CHECK (public.has_role('editor') AND (select auth.uid()) = author_id);
60
61CREATE POLICY "Viewers: read published" ON articles FOR SELECT TO authenticated
62 USING (status = 'published');
63
64-- 5. RLS policies for user_roles
65CREATE POLICY "Read own roles" ON user_roles FOR SELECT TO authenticated
66 USING ((select auth.uid()) = user_id);
67
68CREATE POLICY "Admins manage roles" ON user_roles FOR ALL TO authenticated
69 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.

ChatGPT Prompt

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.

Supabase Prompt

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.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help with your project?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.