Role-Based Access Control (RBAC) in Node.js: Beyond Simple Admin Checks
Most apps start with if (user.role === "admin"). That works until you need editors who can publish but not delete, moderators who can ban but not edit billing, and viewers who can read but not export.
Define Permissions, Not Roles
const PERMISSIONS = {
"articles:read": true, "articles:write": true,
"articles:delete": true, "articles:publish": true,
"users:read": true, "users:manage": true,
"billing:read": true, "billing:manage": true,
} as const;
type Permission = keyof typeof PERMISSIONS;
interface Role { name: string; permissions: Permission[]; }
const ROLES: Record<string, Role> = {
viewer: { name: "Viewer", permissions: ["articles:read", "users:read"] },
editor: { name: "Editor", permissions: ["articles:read", "articles:write", "articles:publish", "users:read"] },
admin: { name: "Admin", permissions: ["articles:read", "articles:write", "articles:delete", "articles:publish", "users:read", "users:manage", "billing:read", "billing:manage"] },
};
Authorization Middleware
function requirePermission(...required: Permission[]) {
return (req: Request, res: Response, next: NextFunction) => {
const userRole = ROLES[req.user.role];
if (\!userRole) return res.status(403).json({ error: "Unknown role" });
const hasAll = required.every((p) => userRole.permissions.includes(p));
if (\!hasAll) return res.status(403).json({ error: "Insufficient permissions" });
next();
};
}
// Usage
app.get("/articles", requirePermission("articles:read"), listArticles);
app.post("/articles", requirePermission("articles:write"), createArticle);
app.delete("/articles/:id", requirePermission("articles:delete"), deleteArticle);
app.put("/billing", requirePermission("billing:manage"), updateBilling);
Resource-Level Permissions
Sometimes role alone is not enough. Users should only edit their own articles:
function requireOwnerOrPermission(permission: Permission) {
return async (req: Request, res: Response, next: NextFunction) => {
const article = await db.article.findById(req.params.id);
if (\!article) return res.status(404).json({ error: "Not found" });
if (article.authorId === req.user.id) return next();
const userRole = ROLES[req.user.role];
if (userRole?.permissions.includes(permission)) return next();
res.status(403).json({ error: "Not authorized" });
};
}
app.put("/articles/:id", requireOwnerOrPermission("articles:write"), updateArticle);
Checking Permissions in Frontend
Send permissions with the auth token so the UI can conditionally render:
function can(permission: string): boolean {
return currentUser.permissions.includes(permission);
}
// In JSX: {can("articles:delete") && <DeleteButton />}
When RBAC Is Not Enough
RBAC breaks down when permissions depend on relationships (team membership, org hierarchy, document sharing). For those cases look at:
- ABAC: policies based on user/resource attributes and environment
- ReBAC: permissions based on entity relationships (Google Zanzibar model)
- Libraries: casbin, casl, or managed services like Permit.io and Oso
Part of my Production Backend Patterns series. Follow for more practical backend engineering.
You Might Also Like
- Environment Variables Done Right: From .env Files to Production Configs
- API Authentication Done Right: JWTs, API Keys, and OAuth2 in Production (2026 Guide)
- Build a Custom API Gateway in Node.js: Routing, JWT Auth, and Rate Limits (2026)
Follow me for more production-ready backend content!
If this helped you, buy me a coffee on Ko-fi!
Top comments (0)