A role based access control api checks two things on every request: does the user's role grant this route's permission, and does the record belong to them? Get both right in one middleware and in your queries, and most authorization bugs disappear.
This tutorial builds that in Node.js with Express 5. For an existing API with 30 to 50 routes, the work typically takes one or two days, plus tests.
The problem
Multi-role apps tend to grow permission checks one route at a time. Six months later, if (user.role === 'admin') sits in 40 handlers, and nobody can say who can do what.
The fix is one permission map, one middleware and tenant-scoped queries. This follows the core model in the NIST RBAC project: users get roles, and roles get permissions.
If you want a second pair of eyes on your permission model, we build this for clients. Ask us about yours and get a review before it ships.
Prerequisites
- Node.js 20 or later.
- Express 5. It passes rejected promises from async middleware to your error handler, and Express 4 does not.
- The jose library for verifying JWTs.
- A database layer. The examples use Prisma, but any query builder works.
- Tokens that carry a user id, a role and an organization id.
How to build a role based access control api in six steps
Step 1: Define permissions, then group them into roles
Name permissions as resource:action. Routes check permissions, never role names. That way, adding a role never means editing handlers.
export const ROLE_PERMISSIONS = { viewer: ['invoice:read'], editor: ['invoice:read', 'invoice:write'], admin: ['invoice:read', 'invoice:write', 'invoice:delete', 'user:manage'] };
Keep this map in one file under version control. A pull request that changes it is a security change, so review it like one.
Step 2: Verify the token and attach the user
import { jwtVerify } from 'jose'; const key = new TextEncoder().encode(process.env.JWT_SECRET); export async function authenticate(req, res, next) { const token = req.headers.authorization?.replace('Bearer ', ''); if (!token) return res.status(401).end(); try { const { payload } = await jwtVerify(token, key, { issuer: 'api.example.com', audience: 'app' }); req.user = { id: payload.sub, role: payload.role, orgId: payload.org }; next(); } catch { return res.status(401).end(); } }
jwtVerify checks the signature, expiry, issuer and audience. This example uses a shared HS256 secret. If your identity provider signs with public keys, use jose's createRemoteJWKSet in place of the static key.
Step 3: Write one permission middleware
export const requirePermission = (perm) => (req, res, next) => { const perms = ROLE_PERMISSIONS[req.user?.role] ?? []; if (!perms.includes(perm)) return res.status(403).json({ error: 'forbidden' }); next(); };
An unknown or missing role resolves to an empty list, so the request is denied. Deny by default is the whole point. A typo in a role name should lock a user out, not let them in.
Step 4: Attach a permission to every route
router.get('/invoices', authenticate, requirePermission('invoice:read'), listInvoices); router.delete('/invoices/:id', authenticate, requirePermission('invoice:delete'), deleteInvoice);
Keep public routes, such as login and health checks, in an explicit allowlist. Every other route needs both middlewares. Step 6's tests catch any route that forgets them.
Step 5: Scope every query to the tenant
const invoice = await prisma.invoice.findFirst({ where: { id: req.params.id, orgId: req.user.orgId } }); if (!invoice) return res.status(404).end();
The role says a user can delete invoices. The query decides which invoices. Return 404 rather than 403 here, so callers cannot probe for records that exist in other organizations.
Step 6: Log every denial
logger.warn({ userId: req.user?.id, role: req.user?.role, perm, path: req.originalUrl }, 'authz denied');
Add this line inside the 403 branch of requirePermission. The argument order shown is pino's: an object first, then a message. A spike of denials from one account means either a bug in your map or someone probing.
The two mistakes that cause production bugs
Checking the role but not the record
This is the most common hole we find in audits. The OWASP API Security Top 10 ranks it first, as broken object level authorization. Every route passes its permission check, but one handler fetches by id alone and leaks another tenant's data.
The fix is mechanical. Every query that takes an id from the request also filters by orgId, or by owner id for user-owned records.
Trusting stale roles in long-lived tokens
JWT claims are fixed when the token is issued (see RFC 7519). If access tokens last 24 hours, a demoted admin stays an admin for up to 24 hours.
You have two options:
- Short-lived tokens: 5 to 15 minute access tokens, with a refresh step that reads the role from the database again. There is no extra per-request cost, but changes take minutes to apply.
- Per-request lookup: read the role from the database, with a 30 to 60 second cache. Changes apply almost at once, at the cost of one fast query per request.
Hiding buttons in the frontend is not a control. Anyone with curl can call the endpoint directly.
How to test it
Write a matrix test with Vitest or Jest and supertest. Each row is a role, a method, a path and the expected status.
const cases = [['viewer', 'delete', '/invoices/1', 403], ['editor', 'delete', '/invoices/1', 403], ['admin', 'delete', '/invoices/1', 204]]; test.each(cases)('%s %s %s', async (role, method, path, status) => { const res = await request(app)[method](path).set('Authorization', `Bearer ${tokenFor(role)}`); expect(res.status).toBe(status); });
tokenFor is a helper you write that signs a test token with the given role. For write routes, send a valid fixture body. Otherwise validation returns 400 and hides the result you are testing.
Add three more groups:
- Cross-tenant requests: a user in org A asks for an org B record and expects 404.
- No token or an expired token: expect 401 on every non-public route.
- An unknown role: expect 403.
Four roles across 30 routes gives 120 cases. That typically runs in a few seconds, which is cheap enough to run on every build.
FAQ
What is the difference between RBAC and ABAC?
RBAC grants permissions through roles. ABAC decides access from attributes, such as department, record status or time of day. Most APIs start with RBAC and add attribute checks in queries, as in Step 5.
Should I put roles in the JWT?
Yes, if access tokens are short-lived, typically 15 minutes or less. With longer-lived tokens, look up the role per request instead. Otherwise role changes and revocations lag behind.
Should an API return 401 or 403?
Return 401 when the caller is not authenticated: the token is missing, invalid or expired. Return 403 when the caller is authenticated but lacks the permission. For records in another tenant, return 404 so you do not reveal that they exist.



