Interact with my blogs by signing in

Building Role-Based Access Control (RBAC) with Supabase and SvelteKit

In this guide, I'll walk you through implementing a complete RBAC system using Supabase as our backend and SvelteKit as our frontend framework.

Introduction
When building modern web applications, securing your data is paramount. Role-Based Access Control (RBAC) is a proven method to manage user permissions by assigning roles that determine what actions users can perform. In this guide, I'll walk you through implementing a complete RBAC system using Supabase as our backend and SvelteKit as our frontend framework.

What We're Building

Our RBAC system will include three user roles: super_admin, admin, and user. Each role will have specific permissions that control access to resources like blogs, comments, and user data. We'll leverage Supabase's Row Level Security (RLS) policies combined with custom JWT claims to create a secure and scalable authorization system.

Part 1: Defining Custom Types

First, we need to define our permission and role types in PostgreSQL. This gives us type safety and makes our authorization logic easier to manage.


Create a new migration file and add the following:


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'
);

create type public.app_role as enum ('super_admin', 'admin', 'user');


The permission enum follows a naming convention: resource.action for global permissions and resource.self.action for permissions that only apply to a user's own data. This distinction is crucial for allowing users to manage their own resources without giving them access to everyone else's data.


Part 2: Creating the RBAC Tables

Now we need two core tables: one to track which roles are assigned to users, and another to define which permissions each role has.

User Roles Table:



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:

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.';


The unique constraints prevent duplicate assignments and keep our data clean.


Part 3: The Custom Access Token Hook

Here's where the magic happens. Supabase allows us to hook into the authentication process and inject custom claims into the JWT token. This means every time a user authenticates, their role is embedded directly in their token.




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

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

claims := jsonb_set(claims, '{user_role}', to_jsonb(user_role));

else

claims := jsonb_set(claims, '{user_role}', 'null');

end if;

event := jsonb_set(event, '{claims}', claims);

return event;

end;

$$;



After creating this function, you need to grant the proper permissions to the supabase_auth_admin role:



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;

grant all

on table public.user_roles

to supabase_auth_admin;

revoke all

on table public.user_roles

from authenticated, anon, public;

create policy "Allow auth admin to read user roles" ON public.user_roles

as permissive for select

to supabase_auth_admin

using (true);


Important: After deploying this migration, you need to enable the hook in your Supabase dashboard. Go to Authentication, then Hooks, and enable the custom access token hook, selecting the custom_access_token_hook function.


Part 4: The Authorization Helper Function

To make writing RLS policies easier, we create a reusable authorization function that checks if the current 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

select (auth.jwt() ->> 'user_role')::public.app_role into user_role;

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 extracts the user's role from their JWT token, then checks the role_permissions table to see if that role has the requested permission. The security definer clause allows this function to query the role_permissions table even though regular users cannot access it directly.


Part 5: Creating the Users Table with Triggers

We want to automatically create entries in our public users table and assign roles whenever someone signs up. Here's the users table:



create table public.users (

user_id uuid unique references auth.users on delete cascade not null primary key,

role app_role not null,

provider text not null,

user_credentials jsonb not null,

created_at timestamp with time zone not null default now(),

updated_at timestamp,

deleted_at timestamp

);


And the trigger function that fires on user creation:




create or replace function public.on_user_insert() returns trigger as $$

declare

var_provider text;

var_user_metadata jsonb;

var_role public.app_role;

begin

var_provider := coalesce(new.raw_app_meta_data ->> 'provider', 'email');

var_user_metadata := coalesce((new.raw_user_meta_data)::jsonb, '{}'::jsonb);

var_role := coalesce((new.raw_user_meta_data ->> 'role')::public.app_role, 'user'::public.app_role);


insert into public.users (user_id, role, provider, user_credentials)
values (new.id,var_role,var_provider,

var_user_metadata || jsonb_build_object('provider', var_provider) || jsonb_build_object('role', var_role));

insert into public.user_roles (user_id, role)
values(new.id,var_role);

return new;

end;

$$ language plpgsql security definer;

create trigger on_user_insert after insert on auth.users for each row execute function public.on_user_insert();


This trigger accomplishes two things: it creates a record in the public.users table for application-specific user data, and it assigns the default "user" role in the user_roles table. If you want to create admin users, you can pass the role in the user metadata during signup.


Part 6: Implementing Row Level Security Policies

Now let's put everything together with RLS policies. Here's an example for a blogs table:



alter table public.blogs enable row level security;


For public read access (anyone can view published blogs):



create policy "Public read access for blogs"

on public.blogs

as permissive

for select

to public

using (true);


For insert, update, and delete operations restricted to super_admin:



create policy "Super admin can insert blogs"

on public.blogs

as permissive

for insert

to authenticated

with check (

(auth.jwt() ->> 'user_role')::public.app_role = 'super_admin'

);

create policy "Super admin can update blogs"

on public.blogs

as permissive

for update

to authenticated

using ((auth.jwt() ->> 'user_role')::public.app_role = 'super_admin')

with check ((auth.jwt() ->> 'user_role')::public.app_role = 'super_admin');


create policy "Super admin can delete blogs"

on public.blogs

as permissive

for delete

to authenticated

using ((auth.jwt() ->> 'user_role')::public.app_role = 'super_admin');


Part 7: Combining Role Checks with Ownership

For tables like comments where users should be able to manage their own data while admins can manage everything, we combine ownership checks with role checks:



alter table public.comments enable row level security;

create policy "Public read access for comments"

on public.comments

as permissive

for select

to public

using (true);

create policy "Users can insert own comments or super admin can insert any"

on public.comments

as permissive

for insert

to authenticated

with check (

auth.uid() = user_id

OR (auth.jwt() ->> 'user_role')::public.app_role = 'super_admin'

);

create policy "Users can update own comments or super admin can update any"

on public.comments

as permissive

for update

to authenticated

using (

auth.uid() = user_id

OR (auth.jwt() ->> 'user_role')::public.app_role = 'super_admin'

)

with check (

auth.uid() = user_id

OR (auth.jwt() ->> 'user_role')::public.app_role = 'super_admin'

);

create policy "Users can delete own comments or super admin can delete any"

on public.comments

as permissive

for delete

to authenticated

using (

auth.uid() = user_id

OR (auth.jwt() ->> 'user_role')::public.app_role = 'super_admin');


Part 8: Using the Authorize Helper Function

For more complex scenarios, you can use the authorize function we created earlier. This is especially useful when you want to check against the role_permissions table instead of hardcoding roles:



create policy "Users can insert own reactions or admins can insert any"

on public.comment_likes_dislikes

as permissive

for insert

to authenticated

with check (

auth.uid() = user_id

OR (auth.jwt() ->> 'user_role')::public.app_role = 'super_admin'

OR public.authorize('full_access'::public.app_permission)

);


This approach gives you flexibility: you can add or remove permissions from roles without changing your RLS policies.


Part 9: Seeding Initial Permissions

Finally, populate your role_permissions table with the initial permission mappings:



insert into public.role_permissions (role, permission)

values

('super_admin', 'full_access'),

('admin', 'users.all'),

('user', 'users.self.all'),

('user', 'users.read');


This gives super_admin full access to everything, admin full access to user management, and regular users the ability to manage only their own data while being able to read other users' public information.


Conclusion

We've built a comprehensive RBAC system that leverages Supabase's powerful features: custom JWT claims inject the user's role into every request, Row Level Security policies enforce access control at the database level, and our authorize helper function provides flexibility for complex permission checks.


This approach has several advantages. Security is enforced at the database level, so even if your application code has bugs, unauthorized access is prevented. The JWT claim approach means role checks are fast since there's no additional database query needed for basic role verification. And the permission system is flexible enough to handle both simple role checks and granular permission requirements.


From your SvelteKit application, you can access the user's role directly from the Supabase session, allowing you to conditionally render UI elements and make authorization decisions on both the client and server side.



Enter your comment here...