---
name: role-based-access-control
description: Implement role-based access control (RBAC) in web applications using tRPC and React. Use when adding admin/manager-only features, restricting endpoints by user role, or implementing permission systems in full-stack TypeScript applications.
---
# Role-Based Access Control (RBAC) for tRPC + React Apps
Implement secure role-based access control in full-stack TypeScript applications using tRPC procedures and React components.
## When to Use This Skill
- Adding admin-only or manager-only features to existing applications
- Restricting specific tRPC endpoints to authorized users
- Implementing multi-tier permission systems (admin, manager, user)
- Hiding UI elements based on user roles
- Building dashboards or tools that require different access levels
## Prerequisites
- tRPC backend with `protectedProcedure` already implemented
- User authentication system in place (OAuth, JWT, or similar)
- User table with `role` field in database schema
- React frontend with auth context or hook (e.g., `useAuth()`)
## Implementation Steps
### Step 1: Ensure Database Schema Has Role Field
Verify the `user` table includes a `role` field:
```typescript
// drizzle/schema.ts or similar
export const user = sqliteTable('user', {
id: integer('id').primaryKey(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
role: text('role', { enum: ['admin', 'user'] }).notNull().default('user'),
// ... other fields
});
```
If the role field doesn't exist, add it and run migrations.
### Step 2: Create Role-Specific Procedures
Add custom procedures for each role level in your tRPC router file:
```typescript
// server/routers.ts or similar
import { protectedProcedure, router } from "./_core/trpc";
import { TRPCError } from "@trpc/server";
// Admin-only procedure
const adminProcedure = protectedProcedure.use(({ ctx, next }) => {
if (ctx.user.role !== 'admin') {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Only administrators can access this resource'
});
}
return next({ ctx });
});
// Manager or admin procedure (if you have multiple admin-like roles)
const managerProcedure = protectedProcedure.use(({ ctx, next }) => {
if (ctx.user.role !== 'admin' && ctx.user.role !== 'manager') {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Only managers and administrators can access this resource'
});
}
return next({ ctx });
});
```
**Key points:**
- Build on top of `protectedProcedure` (assumes user is already authenticated)
- Use `FORBIDDEN` error code (403) for permission denials
- Provide clear error messages for debugging
### Step 3: Apply Role Procedures to Endpoints
Replace `protectedProcedure` with role-specific procedures for restricted endpoints:
```typescript
export const myRouter = router({
// Public endpoint - anyone can access
getPublicData: publicProcedure.query(async () => {
return await getPublicData();
}),
// Protected endpoint - any authenticated user
getUserData: protectedProcedure.query(async ({ ctx }) => {
return await getUserData(ctx.user.id);
}),
// Admin-only endpoint
getAllUsers: adminProcedure.query(async () => {
return await getAllUsers();
}),
// Manager or admin endpoint
getTeamStats: managerProcedure.query(async () => {
return await getTeamStats();
}),
});
```
### Step 4: Add Frontend Role Checks
Protect React components and UI elements based on user role:
```typescript
// pages/AdminDashboard.tsx
import { useAuth } from "@/_core/hooks/useAuth";
import { AlertCircle } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
export function AdminDashboard() {
const { user } = useAuth();
// Check authentication
if (!user) {
return (
Authentication Required
Please log in to access this page.
Access Denied
You do not have permission to access this page. This area is restricted to administrators only.
);
}
// Render admin content
return (
Admin Dashboard
{/* Admin-only content */}
);
}
```
### Step 5: Hide Navigation Links for Unauthorized Users
Conditionally render navigation links based on user role:
```typescript
// pages/Home.tsx or Layout.tsx
import { trpc } from "@/lib/trpc";
import { Link } from "wouter";
import { Button } from "@/components/ui/button";
export function Navigation() {
const { data: user } = trpc.auth.me.useQuery();
return (
);
}
```
**Key points:**
- Use optional chaining (`user?.role`) to handle loading states
- Hide links entirely rather than showing disabled buttons
- Keep role checks consistent between frontend and backend
## Testing Checklist
- [ ] Verify admin users can access admin-only endpoints
- [ ] Verify regular users receive 403 FORBIDDEN errors when accessing admin endpoints
- [ ] Verify navigation links are hidden for unauthorized users
- [ ] Verify protected pages show access denied messages for non-admin users
- [ ] Test with unauthenticated users (should see login prompts)
- [ ] Test role promotion (change user role in database, verify new permissions)
## Common Patterns
### Multiple Role Levels
For applications with more than two roles (e.g., admin, manager, moderator, user):
```typescript
const roleHierarchy = {
admin: 4,
manager: 3,
moderator: 2,
user: 1,
};
const requireRole = (minRole: keyof typeof roleHierarchy) => {
return protectedProcedure.use(({ ctx, next }) => {
const userRoleLevel = roleHierarchy[ctx.user.role];
const requiredLevel = roleHierarchy[minRole];
if (userRoleLevel < requiredLevel) {
throw new TRPCError({
code: 'FORBIDDEN',
message: `This resource requires ${minRole} role or higher`
});
}
return next({ ctx });
});
};
// Usage
const managerProcedure = requireRole('manager'); // allows manager, admin
const moderatorProcedure = requireRole('moderator'); // allows moderator, manager, admin
```
### Resource-Level Permissions
For fine-grained permissions (e.g., "user can only edit their own posts"):
```typescript
const canEditPost = protectedProcedure.use(async ({ ctx, next, input }) => {
const post = await getPostById(input.postId);
if (post.authorId !== ctx.user.id && ctx.user.role !== 'admin') {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You can only edit your own posts'
});
}
return next({ ctx: { ...ctx, post } });
});
```
### Reusable Role Check Hook
Create a custom hook for consistent role checks across components:
```typescript
// hooks/useRequireRole.ts
import { useAuth } from "@/_core/hooks/useAuth";
import { useEffect } from "react";
import { useLocation } from "wouter";
export function useRequireRole(requiredRole: 'admin' | 'manager') {
const { user } = useAuth();
const [, setLocation] = useLocation();
useEffect(() => {
if (!user) {
setLocation('/login');
} else if (user.role !== requiredRole && user.role !== 'admin') {
setLocation('/access-denied');
}
}, [user, requiredRole, setLocation]);
return { user, isAuthorized: user?.role === requiredRole || user?.role === 'admin' };
}
// Usage in component
export function AdminDashboard() {
const { user, isAuthorized } = useRequireRole('admin');
if (!isAuthorized) return null; // Will redirect
return
Admin content
;
}
```
## Security Best Practices
1. **Always validate on the backend** - Frontend checks are for UX only; backend procedures enforce security
2. **Use specific error codes** - `FORBIDDEN` (403) for permission issues, `UNAUTHORIZED` (401) for authentication issues
3. **Fail closed** - Default to denying access unless explicitly granted
4. **Log permission denials** - Track unauthorized access attempts for security monitoring
5. **Avoid role checks in database queries** - Use procedures to centralize authorization logic
6. **Test with different roles** - Verify each role level has correct access
## Troubleshooting
**Error: "Cannot read property 'role' of undefined"**
- User object is not available in context
- Check that `protectedProcedure` is properly configured
- Verify authentication middleware is running before role checks
**Frontend shows admin links but backend denies access**
- Role check logic differs between frontend and backend
- Ensure consistent role field names and values
- Check for typos in role strings ('admin' vs 'Admin')
**User role not updating after database change**
- Session/JWT token still contains old role
- Implement token refresh or require re-login after role changes
- Consider adding role version field to detect stale tokens