Authorization is the gatekeeper of your application. Authentication verifies *who* you are; authorization determines *what* you can do. A well-designed Role-Based Access Control (RBAC) system keeps unauthorized users out while remaining flexible enough to accommodate new roles without code changes.
The Permission Model
At the foundation of RBAC are three entities: Users, Roles, and Permissions. The relationships are:
- A User is assigned one or more Roles.
- A Role contains a set of Permissions.
- A Permission represents a granular action (e.g.,
users:read,orders:delete).
Schema Design (MongoDB)
// Permission embedded in Role for fast lookups
const RoleSchema = new mongoose.Schema({
name: { type: String, required: true, unique: true },
description: { type: String },
permissions: [{ type: String }], // e.g., ['users:read', 'users:write', 'orders:read']
});
const UserSchema = new mongoose.Schema({
email: { type: String, required: true, unique: true },
passwordHash: { type: String, required: true },
roles: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Role' }],
});Embedding permissions as strings in the Role document means a single populate() call gives you everything needed to authorize a request — no joins, no extra queries.
Bitmask Approach for High Performance
For systems with a fixed, manageable set of permissions, bitmask encoding delivers the fastest possible permission checks:
const Permissions = {
USERS_READ: 1 << 0, // 1
USERS_WRITE: 1 << 1, // 2
USERS_DELETE: 1 << 2, // 4
ORDERS_READ: 1 << 3, // 8
ORDERS_WRITE: 1 << 4, // 16
ORDERS_DELETE: 1 << 5, // 32
ADMIN_PANEL: 1 << 6, // 64
} as const;
// Define roles as bitmask sums
const ROLES = {
viewer: Permissions.USERS_READ | Permissions.ORDERS_READ, // 9
editor: Permissions.USERS_READ | Permissions.USERS_WRITE |
Permissions.ORDERS_READ | Permissions.ORDERS_WRITE, // 27
admin: Object.values(Permissions).reduce((a, b) => a | b, 0), // 127
};Permission check is a single bitwise AND — executed in nanoseconds:
function hasPermission(roleMask: number, permission: number): boolean {
return (roleMask & permission) === permission;
}
hasPermission(ROLES.editor, Permissions.ORDERS_WRITE); // true
hasPermission(ROLES.viewer, Permissions.USERS_DELETE); // falseAuthorization Middleware
Create a reusable middleware factory that accepts required permissions:
import { Request, Response, NextFunction } from 'express';
export function authorize(...requiredPermissions: string[]) {
return async (req: Request, res: Response, next: NextFunction) => {
const user = req.user;
if (!user) return res.status(401).json({ error: 'Authentication required' });
// Gather all permissions from user's roles
const userPermissions = new Set(
user.roles.flatMap((role: any) => role.permissions)
);
const hasAll = requiredPermissions.every((p) => userPermissions.has(p));
if (!hasAll) {
return res.status(403).json({
error: 'Forbidden',
required: requiredPermissions,
});
}
next();
};
}Usage on routes:
router.get('/users', authorize('users:read'), usersController.list);
router.delete('/users/:id', authorize('users:delete'), usersController.remove);
router.post('/orders', authorize('orders:write'), ordersController.create);Hierarchical Roles
In many organizations, a Manager should inherit all permissions of a Viewer, and an Admin should inherit all permissions of a Manager. Model this with a parent reference:
const RoleSchema = new mongoose.Schema({
name: { type: String, required: true },
permissions: [{ type: String }],
parent: { type: mongoose.Schema.Types.ObjectId, ref: 'Role', default: null },
});
async function getEffectivePermissions(roleId: string): Promise<string[]> {
const role = await Role.findById(roleId).populate('parent');
let permissions = [...role.permissions];
if (role.parent) {
const parentPerms = await getEffectivePermissions(role.parent._id);
permissions = [...new Set([...permissions, ...parentPerms])];
}
return permissions;
}Cache the resolved permission set in Redis so hierarchical lookups do not hit the database on every request.
Caching Role Data
Roles change infrequently, making them ideal cache candidates:
async function getUserPermissions(userId: string): Promise<string[]> {
const cacheKey = `perms:${userId}`;
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
const user = await User.findById(userId).populate('roles');
const permissions = user.roles.flatMap((r: any) => r.permissions);
await redis.set(cacheKey, JSON.stringify(permissions), 'EX', 3600);
return permissions;
}Invalidate the cache whenever a role is updated or a user's role assignment changes.
Key Takeaways
- Separate authentication from authorization — different concerns, different middleware.
- Use string-based permissions for flexibility or bitmasks for raw speed.
- Cache resolved permissions in Redis — roles change rarely but are checked on every request.
- Model hierarchical roles with parent references and recursive resolution.
- Keep the authorization middleware generic and reusable — never hard-code role names in controllers.
A well-implemented RBAC system is invisible to your users and invaluable to your security posture.
