The first version of role-based access control in any project looks the same. Two roles — admin and user. A middleware that checks req.user.role === 'admin'. Ship it.
Six months later you have eight roles, half the endpoints want "admin OR resource owner," your support team needs read-only access, and somewhere in the code there's an if (user.role === 'admin' || user.email.endsWith('@us.com')). The model didn't grow with the product, and now every new feature requires rethinking who can do what.
The fix isn't a fancier role list. It's separating the things that have always been separate — roles, permissions, and per-record rules — and putting each one in the right layer.
Roles Are Not Permissions
The core mistake is checking roles in route handlers. if (user.role === 'admin') couples the what (delete a post) to the who (a label that means different things in different parts of the app).
The cleaner model:
- Permissions are atomic verbs on resources:
posts:read,posts:delete,users:invite. - Roles are bundles of permissions:
editor=[posts:read, posts:write],admin=[*]. - Routes check permissions, not roles.
export const ROLE_PERMISSIONS = {
admin: ['posts:*', 'users:*', 'billing:*'],
editor: ['posts:read', 'posts:write', 'posts:publish'],
member: ['posts:read'],
viewer: ['posts:read'],
} as const;
export function can(user: { role: keyof typeof ROLE_PERMISSIONS }, perm: string) {
const allowed = ROLE_PERMISSIONS[user.role];
return allowed.some(p => p === perm || p === perm.split(':')[0] + ':*' || p === '*');
}
Now adding "auditor can read posts and users" is a one-line change to the role table, not a hunt through middleware. And when the spec says "editors can publish but not delete," you add posts:publish to the editor row and the routes don't change.
ABAC: When Roles Are Not Enough
The day the spec says "users can edit their own posts," pure RBAC is over. Editing is no longer about who you are — it's about the relationship between the user and the resource. That's attribute-based access control (ABAC).
The pattern in Node:
export function canEditPost(user: User, post: Post) {
if (can(user, 'posts:edit:any')) return true;
if (can(user, 'posts:edit:own') && post.authorId === user.id) return true;
return false;
}
Two permissions, one for "any post," one for "own posts." The check happens after you load the resource, because you need the post.authorId to decide. This is why pure middleware-only authorization breaks down — middleware runs before the resource is loaded.
A common fix is two-stage:
- Middleware confirms the user has some relevant permission (
posts:edit:anyORposts:edit:own). - The handler loads the resource and runs the ownership check before mutating.
If you skip stage 1, every handler does the same boilerplate. If you skip stage 2, you have IDOR vulnerabilities — users editing other users' posts because the URL was guessable.
CASL: Declarative Rules That Stay Out Of Handlers
Once you have a few resource types and a few ownership rules, the imperative if statements pile up. CASL (@casl/ability) lets you express the rules as data:
import { AbilityBuilder, createMongoAbility } from '@casl/ability';
export function abilityFor(user: User) {
const { can, cannot, build } = new AbilityBuilder(createMongoAbility);
if (user.role === 'admin') {
can('manage', 'all');
} else if (user.role === 'editor') {
can('read', 'Post');
can(['update', 'publish'], 'Post', { authorId: user.id });
} else {
can('read', 'Post', { published: true });
}
return build();
}
In handlers, you ask the ability instead of writing branch logic:
const ability = abilityFor(req.user);
if (!ability.can('update', post)) {
return res.status(403).json({ error: 'forbidden' });
}
The big win is auditability. The whole policy lives in one file. A reviewer can read abilityFor and know exactly what each role can do, without grep-ing twenty handlers.
Middleware Pattern That Composes
A small permission middleware factory keeps routes readable:
import type { Request, Response, NextFunction } from 'express';
import { can } from '../auth/permissions.js';
export const requirePermission = (perm: string) =>
(req: Request, res: Response, next: NextFunction) => {
if (!req.user) return res.status(401).json({ error: 'unauthenticated' });
if (!can(req.user, perm)) return res.status(403).json({ error: 'forbidden' });
next();
};
Then routes read like the spec:
router.post('/posts', requirePermission('posts:write'), createPost);
router.delete('/posts/:id', requirePermission('posts:delete'), deletePost);
router.get('/users', requirePermission('users:read'), listUsers);
A reviewer scans the file and sees the access policy without opening any handler.
The Database Should Also Defend Itself
The most expensive RBAC bug I've seen was a query that forgot the tenant filter. The middleware was correct, the handler was correct, but the SQL was SELECT * FROM posts WHERE id = $1 instead of WHERE id = $1 AND tenant_id = $2. One missing condition leaked rows across customers.
Two defenses worth knowing:
- Row-level security (RLS) in Postgres. Define policies once and the database refuses to return rows the current connection isn't allowed to see, no matter what the application sends.
- Tenant-scoped repositories. All data access goes through a layer that injects
tenant_idautomatically; raw SQL outside the repository is a code smell.
You don't have to use RLS. But you do need some layer below the application that enforces the rule, because middleware and handlers will eventually have a bug, and you don't want that bug to be a data breach.
Never Trust The Client
It needs saying because it keeps happening: the frontend hiding a button is not authorization. A useCan hook that conditionally renders the "Delete" action is good UX. It's not security. The same check has to run on the server, ideally in the same shape — which is why frameworks like CASL appeal to teams that want one ability definition for both.
If you only enforce on the frontend, anyone with curl is an admin.
A One-Sentence Mental Model
Roles bundle permissions, permissions are checked in middleware, ownership is checked in handlers after the resource is loaded, and the database has the final word — get those layers right and adding a new role becomes a config change, not a refactor 👊






