Skip to main content

Implementing Role-Based Access Control (RBAC) in Supabase

Role-Based Access Control (RBAC) is a powerful security pattern that simplifies permission management by grouping permissions into roles and assigning those roles to users. When building applications with Supabase, implementing RBAC can significantly improve security, maintainability, and scalability.


Why RBAC Matters


Traditional permission management often requires checking individual permissions for every user action, leading to complex and error-prone code. RBAC solves this by:


  • Centralized Permission Management: All permissions are defined in one place, making it easier to audit and maintain
  • Scalability: Adding new roles or permissions doesn't require refactoring your entire application
  • Security: Permission checks happen at the database level, reducing the risk of unauthorized access
  • Flexibility: Users can have multiple roles, and roles can share permissions
  • JWT Integration: Roles can be embedded in JWT tokens, enabling efficient client-side checks


The Foundation: Custom Types


First, we define the building blocks of our RBAC system using PostgreSQL enums:



// Define application permissions

create type public.app_permission as enum (
'full_access',
'users.create',
'users.read',
'users.update',
'users.delete',
'users.all',
'users.self.create',
'users.self.read',
'users.self.update',
'users.self.delete',
'users.self.all'
);

// Define application roles
create type public.app_role as enum (
'principal_admin',
'admin',
'teacher',
'student'
);


These enums ensure type safety and prevent invalid permissions or roles from being assigned.


The Core Tables



User Roles Table

This table links users to their assigned roles:


create table public.user_roles (
id bigint generated by default as identity primary key,
user_id uuid references auth.users on delete cascade not null,
role app_role not null,
unique (user_id, role)
);

comment on table public.user_roles is 'Application roles for each user.';


Role Permissions Table

This table defines which permissions each role has:


create table public.role_permissions (
id bigint generated by default as identity primary key,
role app_role not null,
permission app_permission not null,
unique (role, permission)
);

comment on table public.role_permissions is 'Application permissions for each role.';



Embedding Roles in JWT Tokens

To make roles available throughout your application, we can embed them in the JWT token using Supabase's access token hook:


create or replace function public.custom_access_token_hook(event jsonb)
returns jsonb
language plpgsql
stable
as $$
declare
claims jsonb;
user_role public.app_role;
begin
-- Fetch the user role from the user_roles table
select role into user_role
from public.user_roles
where user_id = (event->>'user_id')::uuid;

claims := event->'claims';

if user_role is not null then
-- Add the role to JWT claims
claims := jsonb_set(claims, '{user_role}', to_jsonb(user_role));
else
claims := jsonb_set(claims, '{user_role}', 'null');
end if;

-- Update the event with modified claims
event := jsonb_set(event, '{claims}', claims);

return event;
end;
$$;

-- Grant necessary permissions
grant usage on schema public to supabase_auth_admin;

grant execute on function public.custom_access_token_hook
to supabase_auth_admin;

revoke execute on function public.custom_access_token_hook
from authenticated, anon, public;

This hook automatically adds the user's role to every JWT token, making it available both in your database functions and client-side code.


The Authorization Function

The heart of RBAC is a reusable function that checks if a user has a specific permission:



create or replace function public.authorize(
requested_permission app_permission
)
returns boolean as $$
declare
bind_permissions int;
user_role public.app_role;
begin
// Extract user role from JWT token
select (auth.jwt() ->> 'user_role')::public.app_role into user_role;

// Check if the user's role has the requested permission
select count(*)
into bind_permissions
from public.role_permissions
where role_permissions.permission = requested_permission
and role_permissions.role = user_role;

return bind_permissions > 0;
end;
$$ language plpgsql stable security definer set search_path = '';


This function reads the role from the JWT token and checks it against the role_permissions table, returning true if the user has the required permission.


Sample Use Cases



Use Case 1: Row Level Security (RLS) Policies

You can use the authorize() function in RLS policies to control data access:



// Example: Only admins can view all users
create policy "Admins can view all users"
on public.users
for select
to authenticated
using (public.authorize('users.read'));

// Example: Users can only view their own data
create policy "Users can view their own data"
on public.users
for select
to authenticated
using (
auth.uid() = id
OR public.authorize('users.read')
);


Use Case 2: API Endpoints

In your application code, you can check permissions before allowing actions:


// Example: Check permission before creating a user
const hasPermission = await supabase
.rpc('authorize', { requested_permission: 'users.create' });

if (!hasPermission) {
throw new Error('Unauthorized: Cannot create users');
}


Use Case 3: Client-Side Guards

Since roles are in the JWT, you can check them client-side for UI rendering:


// Extract role from JWT
const { data: { user } } = await supabase.auth.getUser();
const role = user?.user_metadata?.user_role;

// Conditionally render admin UI
if (role === 'admin' || role === 'principal_admin') {
// Show admin panel
}



Setting Up Permissions



To populate your RBAC system, you'll need to:


Assign roles to users


insert into public.user_roles (user_id, role)
values ('user-uuid-here', 'admin');


Define role permissions



// Admins can do everything with users
insert into public.role_permissions (role, permission)
values
('admin', 'users.create'),
('admin', 'users.read'),
('admin', 'users.update'),
('admin', 'users.delete');

-- Teachers can only read users
insert into public.role_permissions (role, permission)
values ('teacher', 'users.read');

// Students can only manage their own data
insert into public.role_permissions (role, permission)
values
('student', 'users.self.read'),
('student', 'users.self.update');



Benefits Summary



Implementing RBAC in Supabase provides:


✅ Database-level security - Permissions are enforced at the database layer

✅ JWT integration - Roles are available throughout your stack

✅ Reusable authorization - One function checks all permissions

✅ Type safety - Enums prevent invalid values

✅ Audit trail - All role assignments are tracked in tables

✅ Flexibility - Easy to add new roles or modify permissions


This approach scales from small applications to enterprise systems, providing a robust foundation for access control in your Supabase-powered application.


Have questions or suggestions? Feel free to open an issue on GitHub or contribute to the project! -Mikey


Connect with me!

Github link

Facebook link