Role-Based Access Control
Authentication answers who are you? — but once identity is established, you need to answer what are you allowed to do? Role-Based Access Control (RBAC) is the most common model: users are assigned roles (admin, editor, viewer…), and each protected action requires one or more roles. This page covers the hierarchy, implementing role-checking middleware in Express, attribute-based decisions (ownership checks), and the principle of least privilege.
Roles, permissions, and the hierarchy
RBAC model:
User → assigned one or more Roles
Role → grants a set of Permissions (or is just a label you check)
Permission → a specific allowed action (e.g. "posts:delete", "users:manage")
Flat RBAC (common for small apps):
role: 'admin' | 'editor' | 'user'
→ if (user.role === 'admin') allow
Permission-based (scales better):
user.permissions = ['posts:read', 'posts:write']
→ if (user.permissions.includes('posts:delete')) allowrequireRole middleware
// Call AFTER authenticate middleware — req.user must already be set:
export function requireRole(...roles) {
return (req, res, next) => {
if (!req.user) return res.status(401).json({ error: 'Not authenticated' })
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: 'Forbidden' })
}
next()
}
}
// Usage:
app.delete('/users/:id',
authenticate, // who are you?
requireRole('admin'), // may you do this?
deleteUserHandler,
)
app.put('/posts/:id',
authenticate,
requireRole('admin', 'editor'), // either role is allowed
updatePostHandler,
)Ownership checks (attribute-based authorization)
// A user can edit THEIR OWN posts; admins can edit any post:
app.put('/posts/:id', authenticate, async (req, res) => {
const post = await db.posts.findById(req.params.id)
if (!post) return res.status(404).json({ error: 'Not found' })
const isOwner = post.authorId === req.user.id
const isAdmin = req.user.role === 'admin'
if (!isOwner && !isAdmin) {
return res.status(403).json({ error: 'Forbidden' })
}
const updated = await db.posts.update(req.params.id, req.body)
res.json(updated)
})Never do authorization in the client
Roles in JWT vs database
Approach | Pros | Cons |
|---|---|---|
Role in JWT payload | No DB lookup on each request | Stale if role changes until token expires |
Role loaded from DB | Always fresh — revoke instantly | Extra query per request (cache it) |
Hybrid: role in token + cache bust | Fast + correct | More complexity |
Principle of least privilege
Default to the lowest role — users start as
user; elevation is explicit.Deny by default — routes are protected unless explicitly opened, never the reverse.
Scope API tokens narrowly — if a token only needs read access, don't issue write permission.
Audit role assignments — log who was promoted/demoted and who did it.
Separate admin actions from regular flows — e.g. admin APIs on a separate router with extra checks.