Aus dem Quell-Repository gerendert; Überschriften, Beispiele, Code, Tabellen, Links und Bilder bleiben erhalten.
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
protectedProcedurealready implemented - User authentication system in place (OAuth, JWT, or similar)
- User table with
rolefield 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:
// 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:
// 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
FORBIDDENerror 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({
// 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:
// 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 (
<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>
);
}
// Check authorization
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>
);
}
// Render admin content
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:
// 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 (
<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
- [ ] 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):
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, adminResource-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:
// 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 <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
protectedProcedureis 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
