Web Development · Backend Architecture
RBAC Implementation Guide: Roles, Not If-Statements
A practical RBAC pattern for Node.js and Postgres: roles, permissions, a middleware that checks both, and the traps that turn clean RBAC into unmaintainable spaghetti.
Abhishek Gupta
8 min read
Sponsored
Somewhere in every growing codebase there’s a function that looks like this: if (user.role === 'admin' || user.role === 'owner' || user.email === 'ceo@company.com'). It started as one check. Six months later it’s copy-pasted into eleven route handlers, nobody remembers why the CEO’s email is hardcoded, and adding a new “billing manager” role means grepping the entire codebase. That’s what RBAC without discipline turns into. Role-based access control, done right, is boring: users get roles, roles get permissions, code checks permissions. Here’s the version that stays boring at scale.
The direct answer
Role-based access control inserts one layer of indirection between users and permissions: a user gets one or more roles, each role gets a set of permissions, and application code checks whether the user’s roles grant a specific permission, never whether the user holds a specific role name.
That indirection is the entire point. It lets you add a role or reshuffle what an existing one can do without touching a single route handler, because handlers only ever ask “can this user do X,” not “is this user an admin.” NIST’s RBAC model formalized this separation decades ago, and it still holds up as the default choice for most applications.
The schema
Four tables cover the vast majority of real applications. Resist the urge to add more until something specific forces it.
CREATE TABLE roles (
id SERIAL PRIMARY KEY,
name TEXT UNIQUE NOT NULL, -- 'admin', 'editor', 'viewer'
description TEXT
);
CREATE TABLE permissions (
id SERIAL PRIMARY KEY,
name TEXT UNIQUE NOT NULL -- 'invoices:void', 'posts:publish'
);
CREATE TABLE role_permissions (
role_id INTEGER REFERENCES roles(id) ON DELETE CASCADE,
permission_id INTEGER REFERENCES permissions(id) ON DELETE CASCADE,
PRIMARY KEY (role_id, permission_id)
);
CREATE TABLE user_roles (
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
role_id INTEGER REFERENCES roles(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, role_id)
);
Notice the permission name format: resource:action. invoices:void, posts:publish, users:invite. This convention pays for itself the first time you need to list “everything a role can do to invoices” with a LIKE 'invoices:%' query, and it reads clearly in an audit log without a join.
A user can hold more than one role (user_roles allows it), and a role’s permission set can change without touching any user row. That second property is the whole reason this beats a user.role string column: revoke a permission from editor once, and every editor loses it immediately, no data migration required.
The permission check
Load a user’s permissions once, cache them for the request, and check against that set. Not against role names.
// src/lib/permissions.ts
import { db } from "./db.js";
export async function getUserPermissions(userId: number): Promise<Set<string>> {
const rows = await db.query(
`SELECT DISTINCT p.name
FROM permissions p
JOIN role_permissions rp ON rp.permission_id = p.id
JOIN user_roles ur ON ur.role_id = rp.role_id
WHERE ur.user_id = $1`,
[userId]
);
return new Set(rows.map((r) => r.name));
}
// src/middleware/require-permission.ts
import { Request, Response, NextFunction } from "express";
import { getUserPermissions } from "../lib/permissions.js";
export function requirePermission(permission: string) {
return async (req: Request, res: Response, next: NextFunction) => {
if (!req.user) return res.status(401).json({ error: "Not authenticated" });
const permissions = await getUserPermissions(req.user.id);
if (!permissions.has(permission)) {
return res.status(403).json({ error: `Missing permission: ${permission}` });
}
next();
};
}
// src/routes/invoices.ts
router.post(
"/invoices/:id/void",
requirePermission("invoices:void"),
voidInvoiceHandler
);
That route handler never mentions a role name. Six months from now, when finance wants a “read-only auditor” role that can view invoices but never void them, you add one row to roles, a handful to role_permissions, and ship zero code changes. That’s the test for whether your RBAC is actually working: does a new role require a deploy? If yes, something leaked role-checking into the application code somewhere.
Where flat RBAC stops working
RBAC answers “can a user of this type do this action.” It doesn’t answer “can this specific user edit this specific resource,” which is a different question entirely, and trying to force it into roles is where teams build a role per resource owner and regret it by the hundredth user.
Two patterns handle the ownership case without inflating your role table:
Resource-owner check, application layer. Cheap, explicit, works for most apps:
async function canEditPost(userId: number, postId: number): Promise<boolean> {
const permissions = await getUserPermissions(userId);
if (permissions.has("posts:edit_any")) return true;
const post = await db.query("SELECT author_id FROM posts WHERE id = $1", [postId]);
return post[0]?.author_id === userId && permissions.has("posts:edit_own");
}
Row-level security, database layer. Better as a backstop than a primary mechanism, because it’s easy to forget you have it and hard to unit-test against application logic. Our Postgres row-level security guide covers the setup in more depth, but the short version: RLS policies enforce the ownership rule even if a query path in your application forgets to check it, which is the exact failure mode that turns a missing WHERE author_id = $1 into a data leak.
Use both if the resource is sensitive. Application-layer checks for the common path and clear error messages, RLS as the thing that saves you when a new engineer ships a query that skips the check.
Multi-tenant scoping, in one column
If your app has workspaces, organizations, or any tenant boundary, a flat user_roles table breaks the moment someone needs admin in one workspace and viewer in another. Fix it with one column, not a schema rewrite:
ALTER TABLE user_roles ADD COLUMN context_id INTEGER REFERENCES workspaces(id);
Then the permission query filters on both the user and the context. It’s a small change to getUserPermissions, and it avoids the much larger mistake of building a second, parallel permission system “for the multi-tenant case.” Our multi-tenant SaaS architecture guide goes into the pooled-versus-silo tradeoff that determines how far this scoping needs to go for your specific setup.
RBAC vs ABAC vs ACL
| Model | Grants access based on | Good fit | Gets messy when |
|---|---|---|---|
| RBAC | Role membership | Most business apps: predictable job functions, auditable access | Access depends on runtime context (time, location, resource state) |
| ABAC | Attributes of user, resource, and context evaluated together | Rules like “only during business hours” or “only same department” | You need to explain to an auditor why access was granted, since the logic lives in a rule engine, not a table |
| ACL | A list attached to each individual resource | Fine-grained sharing, like a file system or a document with per-user permissions | The number of resources grows past what anyone can audit by hand |
Most teams reach for RBAC first, hit an ownership edge case, bolt on a resource-owner check (still RBAC, just with one extra query), and never actually need ABAC. If you’re building the access rule for “the CFO can approve refunds under $500 but needs a second approver above that,” that’s the signal you’ve left RBAC’s territory. At that point you’re writing a rules engine, and it’s worth being honest that’s what you’re doing rather than stretching a role name to cover it.
Testing it
RBAC bugs are almost always “this role can do something it shouldn’t,” which is exactly backwards from how most people test: checking that permitted actions work, not that forbidden ones fail.
describe("requirePermission middleware", () => {
it("blocks a viewer from voiding an invoice", async () => {
const res = await request(app)
.post("/invoices/1/void")
.set("Authorization", `Bearer ${viewerToken}`);
expect(res.status).toBe(403);
});
it("allows an admin to void an invoice", async () => {
const res = await request(app)
.post("/invoices/1/void")
.set("Authorization", `Bearer ${adminToken}`);
expect(res.status).toBe(200);
});
it("revokes access immediately after a role is removed", async () => {
await removeRoleFromUser(editorUserId, "editor");
const res = await request(app)
.post("/posts/5/publish")
.set("Authorization", `Bearer ${editorToken}`);
expect(res.status).toBe(403);
});
});
That third test matters more than it looks. If your permission set gets cached per login session instead of per request, revoking a role doesn’t take effect until the user logs out and back in, which is a real gap if you’re revoking access because someone left the company, not because their job changed. Cache per request, or with a short TTL you can live with, never per session.
RBAC done this way is not exciting engineering. That’s the goal. The moment access control needs a design discussion, something in your role model is trying to do too much, and it’s usually cheaper to add one more permission row than to keep bending the code around a role name that no longer means what it used to. If nobody on the team currently owns this as their job, not just as a task that lands on whoever’s free, that’s worth fixing before the role table grows past what one person can audit by memory; our guide to hiring a DevSecOps engineer covers what to screen for when access control stops being a side project.
Frequently asked questions
- What's the difference between RBAC and ABAC?
- RBAC (role-based access control) grants permissions to roles, and users get one or more roles: simple, auditable, fast to reason about. ABAC (attribute-based access control) evaluates rules against attributes of the user, resource, and context at request time, like "allow if user.department equals resource.department and time is business hours." ABAC is more flexible but harder to audit and slower to debug when access looks wrong. Start with RBAC. Move to ABAC only when you have a rule that genuinely can't be expressed as a role, like time-of-day or geography-based access.
- Should permission checks live in the database or the application layer?
- Both, for different reasons. Application-layer checks (middleware, guards) are where you enforce business rules like "only admins can void invoices," and they're what most of this guide covers. Database-layer checks, specifically Postgres row-level security, are your last line of defense against a bug in the application layer, catching the case where a check gets skipped or a query goes through a path you didn't guard. Use application checks for the common case and RLS for defense in depth on anything sensitive.
- How do I handle a user needing different roles in different contexts, like admin in one workspace and viewer in another?
- Scope the role assignment, not the role itself. Instead of a flat `user_roles` table, add a `context_id` (workspace ID, organization ID, whatever your tenant boundary is) so a single user row can hold `admin` in workspace A and `viewer` in workspace B. The permission check then needs both the permission name and the context ID, which is a small addition to the query but changes nothing else about the model.
- Do I need a permissions library, or can I build this myself?
- For a single application with a handful of roles, build it yourself. The schema is four tables and the middleware is under fifty lines; a library adds an abstraction you'll spend more time learning than you'd spend writing the real thing. Reach for something like CASL, Casbin, or Oso once you have genuinely complex policy logic, cross-service authorization, or need the same rules enforced consistently across multiple codebases.
Sponsored
More from this category
More from Web Development
Sponsored
Discussion
Join the conversation.
Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.
Sponsored