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.
TypeScript src/auth/permissions.ts
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:

TypeScript src/auth/canEditPost.ts
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:

  1. Middleware confirms the user has some relevant permission (posts:edit:any OR posts:edit:own).
  2. 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:

TypeScript src/auth/abilities.ts
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:

TypeScript src/routes/posts.ts
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.

Inline diagram showing the three layers of access control — middleware checking permissions, handler checking ownership, and database enforcing row-level rules — with a request flowing through each gate before reaching the data.
Three layers, three gates. Each one catches a different class of mistake.

Middleware Pattern That Composes

A small permission middleware factory keeps routes readable:

TypeScript src/middleware/requirePermission.ts
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:

TypeScript
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_id automatically; 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 👊