| 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:
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'),
});
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:
import { protectedProcedure, router } from "./_core/trpc";
import { TRPCError } from "@trpc/server";
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 });
});
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:
export const myRouter = router({
getPublicData: publicProcedure.query(async () => {
return await getPublicData();
}),
getUserData: protectedProcedure.query(async ({ ctx }) => {
return await getUserData(ctx.user.id);
}),
getAllUsers: adminProcedure.query(async () => {
return await getAllUsers();
}),
getTeamStats: managerProcedure.query(async () => {
return await getTeamStats();
}),
});
Step 4: Add Frontend Role Checks
Protect React components and UI elements based on user role:
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();
if (!user) {
return (
<div className="container mx-auto py-8">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Authentication Required</AlertTitle>
<AlertDescription>
Please log in to access this page.
</AlertDescription>
</Alert>
</div>
);
}
if (user.role !== 'admin') {
return (
<div className="container mx-auto py-8">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Access Denied</AlertTitle>
<AlertDescription>
You do not have permission to access this page. This area is restricted to administrators only.
</AlertDescription>
</Alert>
</div>
);
}
return (
<div className="container mx-auto py-8">
<h1>Admin Dashboard</h1>
{/* Admin-only content */}
</div>
);
}
Step 5: Hide Navigation Links for Unauthorized Users
Conditionally render navigation links based on user role:
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 (
<nav className="flex items-center gap-4">
<Link href="/dashboard">
<Button variant="ghost">Dashboard</Button>
</Link>
<Link href="/profile">
<Button variant="ghost">Profile</Button>
</Link>
{user?.role === 'admin' && (
<Link href="/admin">
<Button variant="ghost">Admin</Button>
</Link>
)}
{(user?.role === 'admin' || user?.role === 'manager') && (
<Link href="/manager">
<Button variant="ghost">Manager</Button>
</Link>
)}
</nav>
);
}
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
Common Patterns
Multiple Role Levels
For applications with more than two roles (e.g., admin, manager, moderator, user):
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 });
});
};
const managerProcedure = requireRole('manager');
const moderatorProcedure = requireRole('moderator');
Resource-Level Permissions
For fine-grained permissions (e.g., "user can only edit their own posts"):
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:
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' };
}
export function AdminDashboard() {
const { user, isAuthorized } = useRequireRole('admin');
if (!isAuthorized) return null;
return <div>Admin content</div>;
}
Security Best Practices
- Always validate on the backend - Frontend checks are for UX only; backend procedures enforce security
- Use specific error codes -
FORBIDDEN (403) for permission issues, UNAUTHORIZED (401) for authentication issues
- Fail closed - Default to denying access unless explicitly granted
- Log permission denials - Track unauthorized access attempts for security monitoring
- Avoid role checks in database queries - Use procedures to centralize authorization logic
- 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