Back to Blog

Building a Role-Based Access Control System From Scratch

Building a Role-Based Access Control System From Scratch

Every multi-user application eventually needs access control. It starts innocently — “admins can do everything, regular users cannot” — and within a few months you have a tangled mess of if (user.role === 'admin') checks scattered across your codebase.

We have built access control systems for several projects. LancerSpace has team workspaces where owners, managers, and members have different capabilities. MindHyv has business owners who can invite team members with restricted access to specific features. Each time, we converged on the same underlying pattern: Role-Based Access Control (RBAC).

RBAC is not complicated, but it is easy to implement badly. Here is how we do it.

The Data Model

RBAC has three core concepts: users, roles, and permissions. Users are assigned roles. Roles contain permissions. Permissions control what actions can be performed on what resources.

Here is the PostgreSQL schema:

-- Permissions define specific actions on resources
create table permissions (
  id uuid primary key default gen_random_uuid(),
  resource text not null,        -- e.g., 'projects', 'invoices', 'team_members'
  action text not null,          -- e.g., 'create', 'read', 'update', 'delete'
  description text,
  created_at timestamptz default now(),
  unique(resource, action)
);

-- Roles group permissions together
create table roles (
  id uuid primary key default gen_random_uuid(),
  name text not null unique,       -- e.g., 'owner', 'manager', 'member', 'viewer'
  description text,
  is_system boolean default false, -- system roles cannot be deleted
  created_at timestamptz default now()
);

-- Which permissions each role has
create table role_permissions (
  role_id uuid references roles(id) on delete cascade,
  permission_id uuid references permissions(id) on delete cascade,
  primary key (role_id, permission_id)
);

-- Which role each user has within an organization/workspace
create table user_roles (
  user_id uuid references auth.users(id) on delete cascade,
  organization_id uuid references organizations(id) on delete cascade,
  role_id uuid references roles(id) on delete cascade,
  assigned_by uuid references auth.users(id),
  assigned_at timestamptz default now(),
  primary key (user_id, organization_id)
);

A few design decisions worth explaining:

Permissions are resource + action pairs. Not strings like "can_edit_projects". The resource/action structure lets you build generic permission checks and generate admin UIs programmatically.

Roles are scoped to organizations. A user might be an owner in one workspace and a viewer in another. The user_roles table captures this with a composite primary key of (user_id, organization_id).

System roles cannot be deleted. Every application needs at least an owner role and a member role. Marking these as system roles prevents accidental deletion through an admin interface.

Seeding Default Roles and Permissions

-- Insert permissions
insert into permissions (resource, action, description) values
  -- Project permissions
  ('projects', 'create', 'Create new projects'),
  ('projects', 'read', 'View projects'),
  ('projects', 'update', 'Edit project details'),
  ('projects', 'delete', 'Delete projects'),
  -- Invoice permissions
  ('invoices', 'create', 'Create invoices'),
  ('invoices', 'read', 'View invoices'),
  ('invoices', 'update', 'Edit invoices'),
  ('invoices', 'delete', 'Delete invoices'),
  ('invoices', 'send', 'Send invoices to clients'),
  -- Team permissions
  ('team_members', 'invite', 'Invite new team members'),
  ('team_members', 'read', 'View team members'),
  ('team_members', 'update', 'Change team member roles'),
  ('team_members', 'remove', 'Remove team members'),
  -- Settings permissions
  ('settings', 'read', 'View workspace settings'),
  ('settings', 'update', 'Modify workspace settings'),
  -- Billing permissions
  ('billing', 'read', 'View billing information'),
  ('billing', 'update', 'Manage billing and subscriptions');

-- Insert roles
insert into roles (name, description, is_system) values
  ('owner', 'Full access to everything. Can manage billing and delete the workspace.', true),
  ('manager', 'Can manage projects, invoices, and team members. Cannot access billing.', true),
  ('member', 'Can work on assigned projects and create invoices.', true),
  ('viewer', 'Read-only access to projects and invoices.', true);

-- Assign permissions to roles
-- Owner gets everything
insert into role_permissions (role_id, permission_id)
select r.id, p.id
from roles r
cross join permissions p
where r.name = 'owner';

-- Manager gets most things except billing and settings
insert into role_permissions (role_id, permission_id)
select r.id, p.id
from roles r
cross join permissions p
where r.name = 'manager'
  and p.resource not in ('billing', 'settings');

-- Member gets limited access
insert into role_permissions (role_id, permission_id)
select r.id, p.id
from roles r
cross join permissions p
where r.name = 'member'
  and (
    (p.resource = 'projects' and p.action in ('read', 'update'))
    or (p.resource = 'invoices' and p.action in ('create', 'read', 'update'))
    or (p.resource = 'team_members' and p.action = 'read')
  );

-- Viewer gets read-only
insert into role_permissions (role_id, permission_id)
select r.id, p.id
from roles r
cross join permissions p
where r.name = 'viewer'
  and p.action = 'read';

This is the permission matrix for LancerSpace. It took us a couple iterations to get right. The first version had too many granular permissions that nobody understood. The current version has 17 permissions across 5 resources — specific enough to be useful, simple enough to explain to users.

User roles and permissions configuration interface with highlighted access levels

The Permission Check Function

The core of RBAC is a single function: “Does this user have this permission in this organization?”

// lib/permissions.ts
import { createClient, SupabaseClient } from '@supabase/supabase-js';

interface PermissionCheck {
  userId: string;
  organizationId: string;
  resource: string;
  action: string;
}

export async function hasPermission(
  supabase: SupabaseClient,
  check: PermissionCheck
): Promise<boolean> {
  const { data, error } = await supabase.rpc('check_permission', {
    p_user_id: check.userId,
    p_organization_id: check.organizationId,
    p_resource: check.resource,
    p_action: check.action,
  });

  if (error) {
    console.error('Permission check failed:', error);
    return false; // Fail closed — deny on error
  }

  return data === true;
}

The actual check runs as a PostgreSQL function for performance — one query instead of multiple joins in application code:

create or replace function check_permission(
  p_user_id uuid,
  p_organization_id uuid,
  p_resource text,
  p_action text
) returns boolean
language sql
stable
security definer
as $$
  select exists (
    select 1
    from user_roles ur
    join role_permissions rp on rp.role_id = ur.role_id
    join permissions p on p.id = rp.permission_id
    where ur.user_id = p_user_id
      and ur.organization_id = p_organization_id
      and p.resource = p_resource
      and p.action = p_action
  );
$$;

The stable marker tells PostgreSQL this function has no side effects and can be optimized. The security definer runs it with the function owner’s privileges, which is important for RLS integration.

API Middleware

Every API endpoint needs a permission check. We use middleware to make this declarative:

// middleware/require-permission.ts
import { hasPermission } from '$lib/permissions';

interface PermissionRequirement {
  resource: string;
  action: string;
}

export function requirePermission(requirement: PermissionRequirement) {
  return async (req: Request, context: RequestContext): Promise<Response | void> => {
    const userId = context.locals.userId;
    const organizationId = context.params.orgId || context.locals.organizationId;

    if (!userId || !organizationId) {
      return new Response('Unauthorized', { status: 401 });
    }

    const allowed = await hasPermission(context.locals.supabase, {
      userId,
      organizationId,
      resource: requirement.resource,
      action: requirement.action,
    });

    if (!allowed) {
      return new Response('Forbidden', { status: 403 });
    }

    // Permission granted — continue to the handler
  };
}

// Usage in an API route
// api/organizations/[orgId]/invoices/+server.ts
export const POST = async (event) => {
  const denied = await requirePermission({ resource: 'invoices', action: 'create' })(
    event.request,
    event
  );

  if (denied) return denied;

  // User has permission — create the invoice
  const body = await event.request.json();
  // ...
};

This pattern ensures every endpoint explicitly declares what permission it requires. If you forget to add the check, the code reviewer (or your future self) will notice the missing requirePermission call.

Admin dashboard settings panel for managing system configurations

Row-Level Security Integration

If you use Supabase, you should enforce permissions at the database level too, not just in your API. This is defense in depth — even if your middleware has a bug, the database rejects unauthorized access.

-- Enable RLS on the projects table
alter table projects enable row level security;

-- Users can only read projects in organizations where they have the 'projects.read' permission
create policy "Users can read projects in their organizations"
  on projects for select
  using (
    check_permission(auth.uid(), organization_id, 'projects', 'read')
  );

-- Users can only insert projects if they have 'projects.create' permission
create policy "Users can create projects in their organizations"
  on projects for insert
  with check (
    check_permission(auth.uid(), organization_id, 'projects', 'create')
  );

-- Users can only update projects if they have 'projects.update' permission
create policy "Users can update projects in their organizations"
  on projects for update
  using (
    check_permission(auth.uid(), organization_id, 'projects', 'update')
  );

-- Users can only delete projects if they have 'projects.delete' permission
create policy "Users can delete projects in their organizations"
  on projects for delete
  using (
    check_permission(auth.uid(), organization_id, 'projects', 'delete')
  );

This is the exact pattern we use in production. The check_permission function runs inside the RLS policy, so every query against the projects table automatically enforces permissions. No middleware required at the database layer.

One caveat: this adds a subquery to every database operation on the table. For high-traffic tables, you might want to cache the user’s permissions in a JWT claim or session variable to avoid repeated lookups. We do this for MindHyv where the booking pages get heavy read traffic.

UI Permission Checks

Permissions should also be enforced in the UI. Not for security (the API and database handle that), but for user experience. Do not show a “Delete Project” button to someone who cannot delete projects.

// lib/permissions-client.ts
// Load the user's permissions once and cache them client-side

interface UserPermissions {
  role: string;
  permissions: { resource: string; action: string }[];
}

let cachedPermissions: UserPermissions | null = null;

export async function loadPermissions(organizationId: string): Promise<UserPermissions> {
  if (cachedPermissions) return cachedPermissions;

  const res = await fetch(`/api/organizations/${organizationId}/my-permissions`);
  cachedPermissions = await res.json();
  return cachedPermissions!;
}

export function can(resource: string, action: string): boolean {
  if (!cachedPermissions) return false;
  return cachedPermissions.permissions.some(
    (p) => p.resource === resource && p.action === action
  );
}

// Clear cache on organization switch
export function clearPermissionCache(): void {
  cachedPermissions = null;
}

In a Svelte component:

<script lang="ts">
  import { can } from '$lib/permissions-client';
</script>

<div class="project-actions">
  {#if can('projects', 'update')}
    <button class="btn-edit">Edit Project</button>
  {/if}

  {#if can('projects', 'delete')}
    <button class="btn-danger">Delete Project</button>
  {/if}

  {#if can('invoices', 'create')}
    <button class="btn-primary">Create Invoice</button>
  {/if}
</div>

The can() function is synchronous because permissions are loaded once when the user enters a workspace. This keeps the UI responsive — no loading states for permission checks.

Role Management UI

Users need to be able to manage roles. At minimum, workspace owners need to invite members and assign roles. Here is the API endpoint for changing a user’s role:

// api/organizations/[orgId]/members/[memberId]/role
export async function updateMemberRole(
  supabase: SupabaseClient,
  organizationId: string,
  requesterId: string,
  memberId: string,
  newRoleName: string
) {
  // Check that the requester has permission to update team members
  const allowed = await hasPermission(supabase, {
    userId: requesterId,
    organizationId,
    resource: 'team_members',
    action: 'update',
  });

  if (!allowed) {
    throw new Error('You do not have permission to change roles');
  }

  // Prevent demoting the last owner
  if (await isLastOwner(supabase, organizationId, memberId)) {
    throw new Error('Cannot change the role of the last owner. Transfer ownership first.');
  }

  // Prevent non-owners from assigning the owner role
  const requesterRole = await getUserRole(supabase, requesterId, organizationId);
  if (newRoleName === 'owner' && requesterRole !== 'owner') {
    throw new Error('Only owners can assign the owner role');
  }

  // Look up the new role
  const { data: role } = await supabase
    .from('roles')
    .select('id')
    .eq('name', newRoleName)
    .single();

  if (!role) throw new Error(`Role "${newRoleName}" does not exist`);

  // Update the user's role
  const { error } = await supabase
    .from('user_roles')
    .update({ role_id: role.id, assigned_by: requesterId })
    .eq('user_id', memberId)
    .eq('organization_id', organizationId);

  if (error) throw error;
}

The “last owner” check is something we learned the hard way. If the only owner demotes themselves, nobody can manage the workspace anymore. Always validate this edge case.

What About Custom Roles?

The four system roles (owner, manager, member, viewer) cover most use cases. But some applications need custom roles — a “Finance” role that can only access invoices and billing, or a “Contractor” role with limited project access.

Our schema supports this already. The is_system flag on the roles table distinguishes system roles from custom ones. Custom roles can be created and configured through the same admin UI.

We do not add custom roles to a project unless there is a clear business need. For LancerSpace, the four default roles have been sufficient for thousands of workspaces. MindHyv needed a custom “Receptionist” role for businesses with front desk staff who should only manage bookings, not see revenue data.

Security lock and authorization system protecting digital access

Performance Considerations

Permission checks happen on every API request. They need to be fast.

Database-level optimization:

-- Composite index for the permission check query
create index idx_user_roles_lookup
  on user_roles(user_id, organization_id);

create index idx_role_permissions_lookup
  on role_permissions(role_id, permission_id);

create index idx_permissions_lookup
  on permissions(resource, action);

Application-level caching: Cache the user’s resolved permissions for the duration of a request. One query at the start of the request, then in-memory lookups for every permission check in that request.

// Resolve all permissions once per request
async function resolvePermissions(
  supabase: SupabaseClient,
  userId: string,
  organizationId: string
): Promise<Set<string>> {
  const { data } = await supabase.rpc('get_user_permissions', {
    p_user_id: userId,
    p_organization_id: organizationId,
  });

  // Returns a Set like: {"projects:read", "projects:update", "invoices:create", ...}
  return new Set(data?.map((p: any) => `${p.resource}:${p.action}`) || []);
}

// Then check with O(1) lookups
const perms = await resolvePermissions(supabase, userId, orgId);
if (perms.has('projects:delete')) {
  // allowed
}

For more on how we cache these lookups across request boundaries, see our post on caching strategies for web apps.

The Principle We Follow

The rule we follow for access control: fail closed, check at every boundary, and make the default restrictive.

If a permission check errors out, deny access. If a new endpoint is added, it should require authentication and authorization by default. If a new feature is built, it should be invisible to users without the right role until explicitly granted.

RBAC is not glamorous. It is plumbing. But it is plumbing that protects your users’ data and your business’s integrity. Get it right from the start, and you never have to untangle a mess of ad-hoc role checks later.

If you are building a multi-user application and need help designing your access control system, reach out at [email protected]. We have implemented RBAC across multiple production applications and can help you get the schema, middleware, and UI right the first time.