Role on what? Fix RBAC in Express and Prisma, with a padlock shield over a project folder and three user roles

Role-Based Access Control in Express and Prisma

Bob just deleted a task from a project nobody invited him to. His role is editor, so the app let him delete tasks. It never asked whose project the task belonged to. That’s the role-based access control most Node tutorials teach, and it’s the first thing a security review flags. I fixed it in an Express and Prisma app with Claude Code. Then I set it up so the next route that forgets its permission check fails the build instead of shipping.

Free: 57 auth mistakes that fail a security review

Why role-based access control on the user breaks

The tutorial version looks fine. The user has a role. The login route puts the user ID and the role into the JWT. The authenticate middleware attaches the user to the request, and requireRole on the delete route lets admins and editors through. Nothing in that chain looks at the project.

That gives you two problems. First, the role answers “is Bob an editor?” The real question is “is Bob an editor of this project?” OWASP ranks this number one on its API Security Top 10 and calls it broken object level authorization.

The second problem is quieter. The role sits in a token Bob holds. If Alice demotes him, his access token keeps saying editor until it expires. Both failures are on my production auth checklist, items 52 and 55.

Ask the database, not the token

A permission is a question about a user and a resource. “Can Bob delete tasks?” has no answer. “Can Bob delete tasks in Project A?” does, and the database knows it. So the token carries only the user ID, and the server looks up Bob’s membership on every request.

I gave Claude Code the design instead of asking it to “add RBAC.” A vague prompt gets you the tutorial version. I replaced the role column on User with a ProjectMember model. Its primary key is the project ID and user ID pair, and each row holds a role. A typed permissions.ts object maps roles to permissions, so a misspelled permission is a compile error. Move that map to a database table only when users need to define roles at runtime. When customers want custom roles or inherited permissions, look at CASL or OpenFGA. Don’t start there.

One authorize function answers every check. The requirePermission middleware calls it. Non-members get a 404, and members without the permission get a 403. A 403 tells an outsider the project exists, so they get the 404.

The bug the permission check didn’t catch

With the new middleware, Bob got a 404 on Alice’s project. Carol, a viewer, got a 403. Then I sent Bob’s delete through Project B, where he’s an editor, with a task ID from Alice’s Project A. The middleware let him through, the server returned a 204, and Alice’s task disappeared.

The permission check was right. The query was wrong, because the handler deleted by task ID alone. Claude Code switched it to deleteMany filtered by task ID and project ID. Prisma’s delete throws when nothing matches. deleteMany returns a count, and a count of zero becomes a 404. The update route had the same hole.

Taking the role out of the token paid off too. I demoted Bob to viewer in Prisma Studio, and his next update got a 403. No logout, no new token. The cost is one primary key lookup on every protected request.

Make a missing check fail the build

A route helper and a lint rule

Any developer can forget requirePermission on a new route. So every route now goes through a helper that requires a permission name or an explicit PUBLIC marker for routes like login and health. Leave it out and TypeScript flags it. An ESLint rule bans router.get, router.post, and the rest outside the helper’s file. I asked Claude Code for an archive endpoint without mentioning permissions. It asked me whether to reuse task:update or add task:archive. Put lint and type checks in Claude Code hooks, and the agent has to fix those failures inside its own loop.

Tests with hand-written expectations

A Vitest and Supertest matrix runs owner, editor, viewer, and non-member against every task action, plus the cross-project case. The expected status codes are typed out by hand. A test that loops over permissions.ts passes even when the map is wrong. I gave the viewer task:delete to check, and the matrix failed.

Key takeaways

  • A role only means something next to a resource, so store it on a project membership instead of on the user.
  • Keep roles out of the JWT and look up membership on every request, so a demotion takes effect on the very next call.
  • Return a 404 to non-members, because a 403 confirms the resource exists.
  • Filter every query by the parent project ID, because a correct permission check can’t save a handler that deletes by task ID alone.
  • Make the permission a required field in a route helper, ban raw router calls with ESLint, and write expected status codes in your tests by hand.

Conclusion

The fix comes down to one question: role on what? A role on the user can’t tell Alice’s project from Bob’s, while a membership row checked on each request can. This week, search your codebase for req.user.role and ask that question at every hit. Start with role-based access control built this way, and change it when a real requirement pushes you.

Share this article

Similar Posts