RBAC in TypeScript: Role-Based Access Control from Backend to Frontend
Ad-hoc permission checks scatter access control logic across every route and component. RBAC centralises it in one typed file. This guide builds the full implementation in TypeScript β role definitions, Express middleware, React hooks, and a single-file change that propagates across your entire stack.
Access control starts simple. Then it grows.
// Week 1
if (user.isAdmin) { ... }
// Week 4
if (user.isAdmin || user.isDoctor) { ... }
// Week 8
if (user.isAdmin || user.isDoctor || user.id === 42) { ... }
// Week 16 β scattered across 40 route handlers and 30 React components
// Nobody knows which roles can do what without reading every fileThis is the access control debt pattern. It happens in every codebase that doesn't start with an explicit model for permissions.
RBAC β Role-Based Access Control β fixes it with a single shift: permissions belong to roles, not to code paths. Define the matrix once, enforce it everywhere.
The Mental Model
ββββββββββββββββββββββββββββββββββββββββββ
β Permission Matrix β
β β
β Permission admin doctor viewerβ
β βββββββββββββββββββββββββββββββββββββ β
β view:records β
β
β
β
β edit:records β
β
β β
β delete:records β
β β β
β view:schedule β
β
β
β
β edit:schedule β
β
β β
β manage:users β
β β β
β view:audit-log β
β β β
ββββββββββββββββββββββββββββββββββββββββββ
β
ββββββββββββββββββ΄βββββββββββββββββ
βΌ βΌ
ββββββββββββββββββββββββ βββββββββββββββββββββββββββ
β Backend β β Frontend β
β β β β
β authorize("edit: β β const can = usePerm- β
β records") β β ission("edit:records") β
β middleware β β hook β
β β β β
β Same source of β β Same source of β
β truth, enforced β β truth, renders UI β
β at the route β β conditionally β
ββββββββββββββββββββββββ βββββββββββββββββββββββββββOne file defines all roles and all permissions. The backend enforces them at the request boundary. The frontend uses them to show or hide UI. Both read from the same source.
A new role? Add a row. A new permission? Add a column. Every route and every component updates automatically β because they don't hardcode role names, they ask the permission matrix.
Step 1: Define Roles and Permissions With Strict Types
Start with the types. TypeScript's type system will prevent permission typos from reaching production.
// src/auth/permissions.ts
// All valid permissions in the system β add here as features grow
export type Permission =
| "view:records"
| "edit:records"
| "delete:records"
| "view:schedule"
| "edit:schedule"
| "manage:users"
| "view:audit-log"
| "export:data";
// All valid roles β add here as your user model grows
export type Role = "admin" | "doctor" | "viewer" | "nurse";
// The permission matrix β Record<Role, Permission[]> enforces that every role is covered
export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
admin: [
"view:records",
"edit:records",
"delete:records",
"view:schedule",
"edit:schedule",
"manage:users",
"view:audit-log",
"export:data",
],
doctor: [
"view:records",
"edit:records",
"view:schedule",
"edit:schedule",
"export:data",
],
nurse: [
"view:records",
"edit:records",
"view:schedule",
],
viewer: [
"view:records",
"view:schedule",
],
};
// The single function everything uses β never check roles directly
export function hasPermission(role: Role, permission: Permission): boolean {
return ROLE_PERMISSIONS[role]?.includes(permission) ?? false;
}
// Check multiple permissions at once
export function hasAllPermissions(role: Role, permissions: Permission[]): boolean {
return permissions.every((p) => hasPermission(role, p));
}
export function hasAnyPermission(role: Role, permissions: Permission[]): boolean {
return permissions.some((p) => hasPermission(role, p));
}The Record<Role, Permission[]> type is load-bearing. If you add a new role to the Role union and forget to add it to ROLE_PERMISSIONS, TypeScript throws a compile error:
Type '{ admin: Permission[]; doctor: Permission[]; viewer: Permission[]; }'
is missing the following properties from type 'Record': nurse The type system enforces completeness. You can't ship a role with no permissions defined.
Step 2: The User Model
Your JWT payload or session carries the role. Everything flows from here:
// src/auth/types.ts
import { Role } from "./permissions";
export interface AuthUser {
id: string;
email: string;
role: Role; // typed β not string
name: string;
}
// JWT payload shape
export interface JwtPayload {
sub: string; // user ID
email: string;
role: Role;
iat: number;
exp: number;
}// src/auth/jwt.ts
import jwt from "jsonwebtoken";
import { JwtPayload, AuthUser } from "./types";
import { Role } from "./permissions";
const SECRET = process.env.JWT_SECRET!;
export function signToken(user: AuthUser): string {
const payload: Omit<JwtPayload, "iat" | "exp"> = {
sub: user.id,
email: user.email,
role: user.role,
};
return jwt.sign(payload, SECRET, { expiresIn: "8h" });
}
export function verifyToken(token: string): JwtPayload {
return jwt.verify(token, SECRET) as JwtPayload;
}Step 3: Express Middleware
Two middleware functions: one to authenticate (extract user from JWT), one to authorize (check a specific permission).
// src/middleware/authenticate.ts
import { Request, Response, NextFunction } from "express";
import { verifyToken } from "../auth/jwt";
import { AuthUser } from "../auth/types";
// Extend Express request type so req.user is typed everywhere
declare global {
namespace Express {
interface Request {
user?: AuthUser;
}
}
}
export function authenticate(req: Request, res: Response, next: NextFunction) {
const header = req.headers.authorization;
if (!header?.startsWith("Bearer ")) {
return res.status(401).json({ error: "Missing token" });
}
try {
const token = header.slice(7);
const payload = verifyToken(token);
req.user = {
id: payload.sub,
email: payload.email,
role: payload.role,
name: "",
};
next();
} catch {
return res.status(401).json({ error: "Invalid token" });
}
}// src/middleware/authorize.ts
import { Request, Response, NextFunction } from "express";
import { Permission, hasPermission } from "../auth/permissions";
// Returns a middleware that checks a specific permission
export function authorize(permission: Permission) {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.user) {
return res.status(401).json({ error: "Not authenticated" });
}
if (!hasPermission(req.user.role, permission)) {
return res.status(403).json({
error: "Forbidden",
required: permission,
userRole: req.user.role,
});
}
next();
};
}
// Multi-permission variant β user must have ALL listed permissions
export function authorizeAll(...permissions: Permission[]) {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.user) return res.status(401).json({ error: "Not authenticated" });
const missing = permissions.filter((p) => !hasPermission(req.user!.role, p));
if (missing.length > 0) {
return res.status(403).json({ error: "Forbidden", missing });
}
next();
};
}Using the Middleware in Routes
// src/routes/records.ts
import { Router } from "express";
import { authenticate } from "../middleware/authenticate";
import { authorize } from "../middleware/authorize";
const router = Router();
// All routes require authentication
router.use(authenticate);
// GET /records β any role that has view:records
router.get("/", authorize("view:records"), async (req, res) => {
const records = await getRecords();
res.json(records);
});
// PUT /records/:id β only roles with edit:records
router.put("/:id", authorize("edit:records"), async (req, res) => {
const record = await updateRecord(req.params.id, req.body);
res.json(record);
});
// DELETE /records/:id β only admin (only role with delete:records)
router.delete("/:id", authorize("delete:records"), async (req, res) => {
await deleteRecord(req.params.id);
res.sendStatus(204);
});
export default router;The route handlers contain zero role logic. They don't know whether admin or doctor can edit records. That knowledge lives in ROLE_PERMISSIONS. If you change which roles can edit records, you change one array β and every route enforces the new rule automatically.
Route layer: authorize("edit:records")
β
βΌ
Permission matrix: doctor: ["view:records", "edit:records", ...]
β
βΌ
Decision: hasPermission("doctor", "edit:records") β true β next()
hasPermission("viewer", "edit:records") β false β 403Step 4: React Hook β usePermission
The frontend needs the same permission checks for rendering. A user without edit:records shouldn't see the Edit button. A user without manage:users shouldn't see the Admin panel link.
// src/hooks/usePermission.ts
import { useAuth } from "./useAuth"; // your existing auth context
import { Permission, hasPermission } from "../auth/permissions";
export function usePermission(permission: Permission): boolean {
const { user } = useAuth();
if (!user) return false;
return hasPermission(user.role, permission);
}
// Check multiple β returns true only if user has all
export function useAllPermissions(...permissions: Permission[]): boolean {
const { user } = useAuth();
if (!user) return false;
return permissions.every((p) => hasPermission(user.role, p));
}
// Check multiple β returns true if user has any
export function useAnyPermission(...permissions: Permission[]): boolean {
const { user } = useAuth();
if (!user) return false;
return permissions.some((p) => hasPermission(user.role, p));
}The hook imports hasPermission from the same permissions.ts that the backend uses. Same function, same logic. No duplication.
Using the Hook in Components
// src/components/RecordActions.tsx
import { usePermission } from "../hooks/usePermission";
export function RecordActions({ recordId }: { recordId: string }) {
const canEdit = usePermission("edit:records");
const canDelete = usePermission("delete:records");
return (
<div className="flex gap-2">
{/* Always visible β if they can see this component, they can view */}
<button onClick={() => viewRecord(recordId)}>View</button>
{/* Only rendered if role has edit:records */}
{canEdit && (
<button onClick={() => editRecord(recordId)}>Edit</button>
)}
{/* Only rendered if role has delete:records β admin only */}
{canDelete && (
<button
className="text-red-500"
onClick={() => deleteRecord(recordId)}
>
Delete
</button>
)}
</div>
);
}// src/components/Sidebar.tsx
import { useAnyPermission, usePermission } from "../hooks/usePermission";
export function Sidebar() {
const canManageUsers = usePermission("manage:users");
const canViewAudit = usePermission("view:audit-log");
const canSeeAdminSection = useAnyPermission("manage:users", "view:audit-log");
return (
<nav>
<a href="/records">Records</a>
<a href="/schedule">Schedule</a>
{/* Admin section β only shown if user has at least one admin permission */}
{canSeeAdminSection && (
<section>
<p>Administration</p>
{canManageUsers && <a href="/admin/users">Users</a>}
{canViewAudit && <a href="/admin/audit">Audit Log</a>}
</section>
)}
</nav>
);
}Step 5: The Can Component (Optional but Clean)
A declarative component alternative to the hook β useful when you want to express authorization inline without extracting a variable:
// src/components/Can.tsx
import { usePermission } from "../hooks/usePermission";
import { Permission } from "../auth/permissions";
interface CanProps {
permission: Permission;
children: React.ReactNode;
fallback?: React.ReactNode;
}
export function Can({ permission, children, fallback = null }: CanProps) {
const allowed = usePermission(permission);
return allowed ? <>{children}</> : <>{fallback}</>;
}// Usage β reads like English
<Can permission="edit:records">
<EditButton recordId={id} />
</Can>
<Can permission="manage:users" fallback={<p>You don't have access to this section.</p>}>
<UserManagementPanel />
</Can>Step 6: Route Guards for Page-Level Access
The hooks protect components. For full page protection, add a route guard:
// src/components/PermissionGate.tsx
import { usePermission } from "../hooks/usePermission";
import { Permission } from "../auth/permissions";
import { Navigate } from "react-router-dom";
interface PermissionGateProps {
permission: Permission;
children: React.ReactNode;
redirectTo?: string;
}
export function PermissionGate({
permission,
children,
redirectTo = "/unauthorized",
}: PermissionGateProps) {
const allowed = usePermission(permission);
if (!allowed) return <Navigate to={redirectTo} replace />;
return <>{children}</>;
}// src/App.tsx β protect entire routes
<Routes>
<Route path="/records" element={
<PermissionGate permission="view:records">
<RecordsPage />
</PermissionGate>
} />
<Route path="/admin/users" element={
<PermissionGate permission="manage:users">
<UserManagementPage />
</PermissionGate>
} />
<Route path="/admin/audit" element={
<PermissionGate permission="view:audit-log">
<AuditLogPage />
</PermissionGate>
} />
</Routes>The Full Data Flow
permissions.ts (single source of truth)
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β export type Permission = "view:records" | "edit:records"... β
β export type Role = "admin" | "doctor" | "viewer" | "nurse" β
β export const ROLE_PERMISSIONS: Record β
β export function hasPermission(role, permission): boolean β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β β
β imported by β imported by
βΌ βΌ
ββββββββββββββββββββββββ ββββββββββββββββββββββββββββββββ
β Express middleware β β React hooks / components β
β β β β
β authorize("edit: β β const can = usePermission( β
β records") β β "edit:records") β
β β β β
β 403 if role lacks β β {can && } β
β the permission β β β
ββββββββββββββββββββββββ ββββββββββββββββββββββββββββββββ
What happens when you add a new role "manager":
1. Add "manager" to the Role union in permissions.ts
2. TypeScript error: ROLE_PERMISSIONS is missing "manager"
3. Add manager: ["view:records", "edit:records", "view:schedule"]
4. Every route and every component that uses hasPermission now
enforces the manager role's permissions automatically.
5. No other files need to change. Handling Resource-Level Permissions
RBAC as described so far is action-level: "can this role edit records?" A common extension is resource-level: "can this doctor edit their patient's record?"
// src/auth/permissions.ts β extend with ownership check type
export type OwnershipCheck<T> = (user: AuthUser, resource: T) => boolean;
// Standard ownership: user owns the resource
export const isOwner: OwnershipCheck<{ userId: string }> = (user, resource) =>
user.id === resource.userId;
// Doctor-patient relationship
export const isTreatingDoctor: OwnershipCheck<{ treatingDoctorId: string }> = (
user,
record
) => user.id === record.treatingDoctorId;// src/middleware/authorizeResource.ts
export function authorizeResource<T>(
permission: Permission,
getResource: (req: Request) => Promise<T>,
ownershipCheck: OwnershipCheck<T>
) {
return async (req: Request, res: Response, next: NextFunction) => {
if (!req.user) return res.status(401).json({ error: "Not authenticated" });
// Admin bypasses ownership β has global permission
if (req.user.role === "admin") return next();
// Check base permission first
if (!hasPermission(req.user.role, permission)) {
return res.status(403).json({ error: "Forbidden" });
}
// Check ownership
const resource = await getResource(req);
if (!ownershipCheck(req.user, resource)) {
return res.status(403).json({ error: "Not your resource" });
}
next();
};
}// Route with ownership check
router.put(
"/records/:id",
authorizeResource(
"edit:records",
(req) => getRecord(req.params.id), // fetch the resource
(user, record) => record.treatingDoctorId === user.id // ownership predicate
),
async (req, res) => {
// Reaches here only if: role has edit:records AND user is the treating doctor
// (OR user is admin β admin bypass is in the middleware)
const record = await updateRecord(req.params.id, req.body);
res.json(record);
}
);.NET Equivalent β ASP.NET Core Policy Authorization
If your backend is ASP.NET Core rather than Node.js, the same pattern maps directly to policy-based authorization:
// Program.cs β define policies from the same permission matrix concept
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("view:records", policy =>
policy.RequireAssertion(ctx =>
ctx.User.IsInRole("admin") ||
ctx.User.IsInRole("doctor") ||
ctx.User.IsInRole("viewer")));
options.AddPolicy("edit:records", policy =>
policy.RequireAssertion(ctx =>
ctx.User.IsInRole("admin") ||
ctx.User.IsInRole("doctor")));
options.AddPolicy("manage:users", policy =>
policy.RequireRole("admin"));
});Or using a requirement + handler approach for the full typed matrix:
// PermissionRequirement.cs
public record PermissionRequirement(string Permission) : IAuthorizationRequirement;
// PermissionHandler.cs
public class PermissionHandler : AuthorizationHandler<PermissionRequirement>
{
private static readonly Dictionary<string, string[]> RolePermissions = new()
{
["admin"] = ["view:records", "edit:records", "delete:records", "manage:users"],
["doctor"] = ["view:records", "edit:records", "view:schedule"],
["viewer"] = ["view:records", "view:schedule"],
};
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
PermissionRequirement requirement)
{
var role = context.User.FindFirst(ClaimTypes.Role)?.Value;
if (role != null &&
RolePermissions.TryGetValue(role, out var perms) &&
perms.Contains(requirement.Permission))
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
}
// Controller usage β identical concept to Express
[HttpPut("{id}")]
[Authorize(Policy = "edit:records")]
public async Task<IActionResult> UpdateRecord(Guid id, UpdateRecordRequest request) { ... }Common Mistakes
Mistake 1: Checking role names instead of permissions
// β Route now knows that doctors can edit β coupling business logic to infrastructure
if (req.user.role === "admin" || req.user.role === "doctor") { ... }
// β
Route asks what the user can do β the matrix decides who that includes
authorize("edit:records")Mistake 2: Only enforcing on the frontend
// β Hiding a button is not authorization β anyone can call the API directly
{user.role === "admin" && <DeleteButton />} // delete route has no middleware
// β
Backend enforces, frontend mirrors
// Backend: router.delete("/:id", authorize("delete:records"), handler)
// Frontend: {canDelete && <DeleteButton />}Mistake 3: Hardcoding role lists in middleware
// β Now the middleware knows role names β change the matrix and forget the middleware
function canEdit(req, res, next) {
if (!["admin", "doctor"].includes(req.user.role)) return res.status(403)...
}
// β
Middleware only knows the permission name β the matrix resolves the roles
authorize("edit:records")Summary
The RBAC contract:
permissions.ts defines:
- Every permission in the system (typed union)
- Every role in the system (typed union)
- Which permissions each role has (Record)
- The hasPermission() function
The backend uses:
- authenticate() β extracts user from JWT, types req.user
- authorize(permission) β checks hasPermission, returns 403 if denied
The frontend uses:
- usePermission(permission) β returns boolean from hasPermission
- β declarative conditional rendering
- β full page route guard
What changes when:
New permission β add to Permission union, add to role arrays
New role β add to Role union, TypeScript forces you to add permissions
Role gains permission β add to one array in ROLE_PERMISSIONS
Role loses permission β remove from one array
Nothing else changes. Every route and component enforces automatically. If your codebase has an if (user.isAdmin || user.isDoctor) anywhere, that logic belongs in the permission matrix. Move it there once, and you never write it twice.
Enjoyed this article?
Explore the Security & Compliance learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.