Back to blog
Security & Complianceintermediate

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.

LearnixoApril 19, 202613 min read
TypeScriptNode.jsReactSecurityRBACAuthorizationExpressASP.NET Core
Share:𝕏

Access control starts simple. Then it grows.

TYPESCRIPT
// 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 file

This 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.

TYPESCRIPT
// 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:

TYPESCRIPT
// 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;
}
TYPESCRIPT
// 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).

TYPESCRIPT
// 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" });
  }
}
TYPESCRIPT
// 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

TYPESCRIPT
// 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 β†’ 403

Step 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.

TYPESCRIPT
// 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

TSX
// 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>
  );
}
TSX
// 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:

TSX
// 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}</>;
}
TSX
// 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:

TSX
// 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}</>;
}
TSX
// 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?"

TYPESCRIPT
// 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;
TYPESCRIPT
// 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();
  };
}
TYPESCRIPT
// 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:

C#
// 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:

C#
// 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

TYPESCRIPT
// ❌ 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

TSX
// ❌ 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

TYPESCRIPT
// ❌ 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?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.