Most of the auth identity products on the market will sell you RBAC as a feature. For a small team or a side project, the cheapest version of "role-based access control" is usually a hundred lines of plain code, a couple of indexed collections, and the discipline to keep the policy in one place. This is the version I keep reaching for.
The setup below assumes Fastify on the API side, MongoDB for storage, and session-based auth via @fastify/session. The same shape works with JWTs — the only thing that really changes is where the role list lives in transit.
The shape of the data
Three collections. users, roles, and permissions. Roles are named groups of permission strings. Users have a list of role IDs. That's it — there is no fourth collection for "user permissions" and no inheritance graph. You can build either of those later if you actually need them, and most teams don't.
// roles
{ _id, name: 'editor', permissions: ['post:read','post:write','post:publish'] }
// users
{ _id, email, roles: [ObjectId('...editor...')] }
The permission strings are the policy. They follow a resource:action shape — post:read, billing:refund, user:impersonate. The grammar matters more than people think: it makes route guards readable out loud, which makes audits possible without a meeting.
Resolving permissions on the request
A single Fastify preHandler hook resolves the user's effective permission set once per request and stashes it on request.permissions. After that, route guards are a one-liner.
fastify.addHook('preHandler', async (request) => {
if (!request.session.userId) return;
const user = await users.findOne({ _id: request.session.userId });
const roles = await roles.find({ _id: { $in: user.roles } }).toArray();
request.permissions = new Set(roles.flatMap(r => r.permissions));
});
const requirePermission = (perm) => async (request, reply) => {
if (!request.permissions?.has(perm)) {
return reply.code(403).send({ error: 'forbidden', need: perm });
}
};
fastify.post('/posts', { preHandler: requirePermission('post:write') }, handler);
The 403 returns the missing permission string. That sounds like an information leak; in practice it is the single biggest quality-of-life improvement you can give your support team. "Forbidden" is useless. "Forbidden, you need billing:refund" turns a ticket into a one-line role update.
Audit logging is the load-bearing wall
RBAC without an audit trail is just a polite suggestion. Every successful permission check that mutates state writes a row: who, what permission, what resource, when, and the request ID. Nothing fancy — a capped collection in Mongo and a small admin view that filters by user and date range.
If you can't answer "who changed this?" in under thirty seconds, you don't have RBAC. You have a wishlist.
What I deliberately don't build
- Hierarchical roles. "Admin inherits from editor inherits from author" sounds clean and rots fast. Flat lists with a little duplication are easier to read and easier to revoke.
- Object-level ACLs. If you need "user X can edit post Y," that's an ownership check, not RBAC. Keep it on the resource:
post.authorId === user._id. Two different problems, two different mechanisms. - UI permission mirrors. The frontend gets a flat list of permission strings on login and uses them to hide buttons. The server still checks every single mutation. The UI is a hint; the API is the law.
When to graduate
This pattern carries a surprisingly long way. The honest signal that you've outgrown it is when you start writing permission strings like post:write:if-draft-and-author. That's a policy engine asking to be born — at that point reach for OPA or Cedar and don't try to grow this into one. Until then, a hundred lines of plain code is the right amount of code.