RBAC & Role Claims Design
Cross-Module · AUTH-18, ADM-05 · Priority: P1
Why It Matters
Most AI-built apps implement roles as a simple binary: admin or user. This means every admin has identical, unlimited access — there's no distinction between "can manage billing" and "can delete all users." When a single admin role controls everything, one compromised account means total access.
Proper RBAC (Role-Based Access Control) separates permissions by function — billing manager, support agent, super admin — so each role only has access to what it needs. SOC 2 compliance requires least-privilege access and documented role definitions.
Priority: P1 — Required for multi-user teams and enterprise readiness.
Affected Stack: Supabase Auth, Next.js, any framework with role-based access
AUTH-18: RBAC in app_metadata
The Problem
AI tools store roles in user_metadata — which users can modify themselves through Supabase's client API. Or worse, roles are stored in a profiles table without RLS, making them editable by any authenticated user.
// ❌ Role in user_metadata — user can self-promote
const { data } = await supabase.auth.updateUser({
data: { role: 'admin' } // User calls this themselves!
});
The Fix
Store roles in app_metadata, which can only be modified by the service role (server-side). Users cannot change their own app_metadata.
// ✅ Set role via service role only (server-side)
const { error } = await supabaseAdmin.auth.admin.updateUserById(userId, {
app_metadata: { role: 'billing_manager' }
});
// ✅ Read role from app_metadata (cannot be tampered with)
const { data: { user } } = await supabase.auth.getUser();
const role = user?.app_metadata?.role; // 'admin', 'billing_manager', 'support'
ADM-05: RBAC Beyond Binary
The Problem
Binary admin/user roles create two issues:
- Over-privileged admins — every admin can do everything
- No growth path — when you need "support can view users but not delete" or "billing can manage subscriptions but not access admin settings," binary roles can't express it
The Fix
Design roles around job functions, not access levels.
// ✅ Role hierarchy with specific permissions
const ROLE_PERMISSIONS: Record<string, string[]> = {
super_admin: ['*'], // Full access
billing_manager: [
'billing.view', 'billing.modify', 'subscriptions.manage',
'users.view' // Can view users but not modify
],
support: [
'users.view', 'users.impersonate',
'tickets.manage'
],
member: [
'own_data.view', 'own_data.modify'
],
};
// ✅ Permission check helper
function hasPermission(role: string, permission: string): boolean {
const perms = ROLE_PERMISSIONS[role] || [];
return perms.includes('*') || perms.includes(permission);
}
// ✅ Use in API route
if (!hasPermission(user.app_metadata.role, 'users.delete')) {
return new Response('Forbidden', { status: 403 });
}
Key rules:
- Define roles based on job functions, not technical access levels
- Store role definitions in a shared config (not scattered across components)
- Use
app_metadatafor role storage (notuser_metadataor client-editable tables) - Implement least-privilege: start with no access, grant only what's needed
- Document role permissions for SOC 2 compliance
References
Related Checks
- No Client-Side-Only Role Checks — ADM-03
- Server-Side Auth for Protected Routes — AUTH-04
- MFA for Admin Roles — ADM-13
Is your app safe? Run Free Scan →