Almost every REST API reaches the same turning point: a single isAdmin boolean is no longer enough. You need editors who can publish but not delete users, support agents who can read orders but not refund them, and admins who can do everything. That is exactly the problem role based access control in Node.js solves.
This tutorial walks through a complete, production-ready RBAC implementation for an Express REST API: how to model roles and permissions in the database, what to put inside the JWT payload, how to write middleware that blocks unauthorized requests before the controller ever runs, and which mistakes silently turn your API into an open door.
What You Will Build
- A normalized users / roles / permissions schema (SQL and Mongoose versions)
- A JWT payload designed for authorization, not just authentication
- Reusable Express middleware:
requireAuth,requireRole,requirePermission,requireMinLevel - An admin / editor / user hierarchy with numeric levels
- Resource ownership checks (“an editor may update their own post only”)
- A test plan with curl and a list of common RBAC failures
RBAC Vocabulary in 60 Seconds
| Term | Meaning | Example |
|---|---|---|
| Authentication | Who are you? | Valid JWT signature |
| Authorization | What are you allowed to do? | RBAC check |
| Role | A named bundle of permissions | editor |
| Permission | A single action on a resource | post:publish |
| Ownership rule | Permission scoped to a record | update own post |
Golden rule: assign permissions to roles, and roles to users. Never hard-code a user ID inside a permission check.

Step 1: Design the Permission Matrix Before You Write Code
Start on paper. A three-tier hierarchy covers the majority of SaaS products:
| Permission | user (level 10) | editor (level 50) | admin (level 100) |
|---|---|---|---|
| post:read | Yes | Yes | Yes |
| post:create | No | Yes | Yes |
| post:update (own) | No | Yes | Yes |
| post:update (any) | No | No | Yes |
| post:delete | No | No | Yes |
| user:manage | No | No | Yes |
This matrix becomes your seed data. If you cannot fill this table, you are not ready to write middleware. tericcabrel.com walks through the specifics.
Step 2: Model Roles and Permissions in the Database
PostgreSQL / MySQL schema
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
token_version INT NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE roles (
id SERIAL PRIMARY KEY,
name VARCHAR(50) UNIQUE NOT NULL,
level INT NOT NULL DEFAULT 0
);
CREATE TABLE permissions (
id SERIAL PRIMARY KEY,
resource VARCHAR(50) NOT NULL,
action VARCHAR(50) NOT NULL,
UNIQUE (resource, action)
);
CREATE TABLE role_permissions (
role_id INT REFERENCES roles(id) ON DELETE CASCADE,
permission_id INT REFERENCES permissions(id) ON DELETE CASCADE,
PRIMARY KEY (role_id, permission_id)
);
CREATE TABLE user_roles (
user_id INT REFERENCES users(id) ON DELETE CASCADE,
role_id INT REFERENCES roles(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, role_id)
);
Three design decisions worth defending:
- Many-to-many user_roles instead of a single
rolecolumn. Adding a second role later without a migration is worth the extra join. - A numeric
levelon roles. It gives you cheap hierarchy checks such as “level 50 or above” without walking a graph. - A
token_versioninteger on users. When you demote someone, you increment it and every JWT issued before that moment stops working. This is the answer to the biggest weakness of stateless tokens.
Mongoose alternative
const { Schema, model } = require('mongoose');
const roleSchema = new Schema({
name: { type: String, required: true, unique: true },
level: { type: Number, required: true, default: 0 },
permissions: [{ type: String }] // 'post:read', 'post:update', 'user:manage'
});
const userSchema = new Schema({
email: { type: String, required: true, unique: true, lowercase: true },
passwordHash: { type: String, required: true },
roles: [{ type: Schema.Types.ObjectId, ref: 'Role' }],
tokenVersion: { type: Number, default: 0 },
isActive: { type: Boolean, default: true }
}, { timestamps: true });
module.exports = {
Role: model('Role', roleSchema),
User: model('User', userSchema)
};
Seeding roles and permissions
// scripts/seed-rbac.js
const ROLES = [
{
name: 'user',
level: 10,
permissions: ['post:read', 'profile:read', 'profile:update']
},
{
name: 'editor',
level: 50,
permissions: ['post:read', 'post:create', 'post:update', 'post:publish',
'profile:read', 'profile:update']
},
{
name: 'admin',
level: 100,
permissions: ['post:*', 'user:*', 'profile:*', 'audit:read']
}
];
async function seed(db) {
for (const role of ROLES) {
await db.upsertRole(role);
}
console.log('RBAC seed completed');
}
Note the wildcard convention post:*. It keeps the admin row short and it is trivial to evaluate, as you will see in the permission checker.
Step 3: Put the Right Claims in the JWT Payload
The middleware will read authorization data from the decoded token, so the payload design matters more than any other choice in this tutorial.
// services/token.service.js
const jwt = require('jsonwebtoken');
function signAccessToken(user) {
const payload = {
sub: String(user.id),
roles: user.roles.map(r => r.name), // ['editor']
perms: flattenPermissions(user.roles), // ['post:read', 'post:create', ...]
lvl: Math.max(...user.roles.map(r => r.level)),
ver: user.tokenVersion
};
return jwt.sign(payload, process.env.JWT_ACCESS_SECRET, {
expiresIn: '15m',
issuer: 'api.santiance.com',
audience: 'santiance-app',
algorithm: 'HS256'
});
}
function flattenPermissions(roles) {
const set = new Set();
roles.forEach(r => r.permissions.forEach(p => set.add(p)));
return [...set];
}
module.exports = { signAccessToken };
Roles in the token or a database lookup on every request?
| Approach | Pros | Cons | Use when |
|---|---|---|---|
| Claims embedded in JWT | Zero DB hits, fast, scales horizontally | Stale until the token expires, larger token | Short-lived access tokens (5 to 15 min) |
| Lookup per request | Always current, instant revocation | One query on every call | High-risk actions, banking, healthcare |
| Hybrid (recommended) | Claims in token plus a cached tokenVersion check |
Needs Redis or an equivalent cache | Most production APIs |
Keep access tokens short-lived. A 15 minute access token plus a rotating refresh token means a demoted editor loses elevated rights within 15 minutes at worst, and immediately if you bump tokenVersion.

Step 4: The Authentication Middleware
Authentication comes first and stays separate from authorization. One responsibility per middleware.
// middleware/requireAuth.js
const jwt = require('jsonwebtoken');
const cache = require('../services/cache'); // Redis wrapper
module.exports = async function requireAuth(req, res, next) {
const header = req.headers.authorization || '';
const [scheme, token] = header.split(' ');
if (scheme !== 'Bearer' || !token) {
return res.status(401).json({ error: 'MISSING_TOKEN' });
}
try {
const decoded = jwt.verify(token, process.env.JWT_ACCESS_SECRET, {
issuer: 'api.santiance.com',
audience: 'santiance-app',
algorithms: ['HS256'] // never trust the alg header from the client
});
const currentVersion = await cache.getTokenVersion(decoded.sub);
if (currentVersion !== null && currentVersion !== decoded.ver) {
return res.status(401).json({ error: 'TOKEN_REVOKED' });
}
req.user = {
id: decoded.sub,
roles: decoded.roles || [],
permissions: decoded.perms || [],
level: decoded.lvl || 0
};
return next();
} catch (err) {
const code = err.name === 'TokenExpiredError' ? 'TOKEN_EXPIRED' : 'INVALID_TOKEN';
return res.status(401).json({ error: code });
}
};
Two details people skip and later regret:
- Pinning
algorithms: ['HS256']blocks the classic alg: none and algorithm confusion attacks. - Validating
issuerandaudiencestops a token minted for another service from being replayed against your API.
Step 5: The RBAC Middleware Factories
These are the functions you will use on every protected route. They are factories: they take configuration and return an Express middleware.
// middleware/rbac.js
function deny(res, reason) {
return res.status(403).json({ error: 'FORBIDDEN', reason });
}
// Wildcard aware matcher: 'post:*' satisfies 'post:delete'
function hasPermission(granted, required) {
const [resource] = required.split(':');
return granted.includes(required)
|| granted.includes(`${resource}:*`)
|| granted.includes('*:*');
}
// requireRole('admin', 'editor') -> passes if the user holds ANY of them
function requireRole(...allowed) {
return (req, res, next) => {
if (!req.user) return res.status(401).json({ error: 'UNAUTHENTICATED' });
const ok = req.user.roles.some(r => allowed.includes(r));
return ok ? next() : deny(res, `requires one of: ${allowed.join(', ')}`);
};
}
// requirePermission('post:delete')
function requirePermission(...required) {
return (req, res, next) => {
if (!req.user) return res.status(401).json({ error: 'UNAUTHENTICATED' });
const ok = required.every(p => hasPermission(req.user.permissions, p));
return ok ? next() : deny(res, `requires: ${required.join(', ')}`);
};
}
// requireMinLevel(50) -> editor and admin pass, user does not
function requireMinLevel(minLevel) {
return (req, res, next) => {
if (!req.user) return res.status(401).json({ error: 'UNAUTHENTICATED' });
return req.user.level >= minLevel ? next() : deny(res, `requires level ${minLevel}`);
};
}
module.exports = { requireRole, requirePermission, requireMinLevel, hasPermission };
Which one should you use?
- requirePermission is the default choice. Routes describe capabilities, not job titles, so renaming a role or adding a “moderator” never forces you to touch route files.
- requireRole is fine for a handful of genuinely role-shaped endpoints such as an admin dashboard.
- requireMinLevel shines for strictly hierarchical products where every higher role is a superset of the one below.
Step 6: Ownership Checks for Row-Level Rules
“An editor can update their own post, an admin can update any post” cannot be answered by the token alone, because it depends on the record. Handle it in a dedicated middleware that loads the resource once and reuses it in the controller.
// middleware/ownership.js
const Post = require('../models/post');
const { hasPermission } = require('./rbac');
function loadPostAndAuthorize(action) {
return async (req, res, next) => {
const post = await Post.findById(req.params.id);
if (!post) return res.status(404).json({ error: 'NOT_FOUND' });
const isOwner = String(post.authorId) === String(req.user.id);
const canAny = hasPermission(req.user.permissions, `post:${action}:any`)
|| req.user.roles.includes('admin');
const canOwn = hasPermission(req.user.permissions, `post:${action}`);
if (canAny || (isOwner && canOwn)) {
req.resource = post; // avoid a second query in the controller
return next();
}
return res.status(403).json({ error: 'FORBIDDEN', reason: 'not the owner' });
};
}
module.exports = { loadPostAndAuthorize };
Return 404 instead of 403 when the mere existence of a record is confidential. A 403 on /api/invoices/8123 confirms that invoice 8123 exists, which is an information leak in multi-tenant apps.

Step 7: Protect the Routes
// routes/posts.routes.js
const router = require('express').Router();
const requireAuth = require('../middleware/requireAuth');
const { requireRole, requirePermission, requireMinLevel } = require('../middleware/rbac');
const { loadPostAndAuthorize } = require('../middleware/ownership');
const ctrl = require('../controllers/posts.controller');
// Public
router.get('/', ctrl.list);
// Any authenticated user
router.get('/:id', requireAuth, requirePermission('post:read'), ctrl.getOne);
// Editors and admins
router.post('/', requireAuth, requirePermission('post:create'), ctrl.create);
// Owner-or-admin
router.patch('/:id', requireAuth, loadPostAndAuthorize('update'), ctrl.update);
// Admins only
router.delete('/:id', requireAuth, requirePermission('post:delete'), ctrl.remove);
// Hierarchy shortcut: level 50 and above
router.post('/:id/publish', requireAuth, requireMinLevel(50), ctrl.publish);
module.exports = router;
For an entire admin area, mount the guards once on the router rather than repeating them on twelve routes:
// app.js
const adminRouter = require('./routes/admin.routes');
app.use('/api/admin', requireAuth, requireRole('admin'), adminRouter);
app.use('/api/posts', require('./routes/posts.routes'));
Order matters
Express runs middleware in the order it is declared. requireAuth must always come before any RBAC middleware, otherwise req.user is undefined and your guard either crashes or, worse, silently lets the request through if it was written carelessly. Originally covered on https://osohq.com.
Step 8: Consistent Error Responses
// middleware/errorHandler.js
module.exports = (err, req, res, next) => {
const status = err.status || 500;
if (status === 500) {
console.error({ msg: err.message, path: req.path, userId: req.user && req.user.id });
}
res.status(status).json({
error: err.code || 'INTERNAL_ERROR',
message: status === 500 ? 'Unexpected error' : err.message
});
};
Use the right status code, every time:
- 401 Unauthorized: no token, expired token, invalid signature. The client should refresh or log in again.
- 403 Forbidden: valid identity, insufficient rights. Refreshing the token changes nothing.
- 404 Not Found: resource hidden on purpose in multi-tenant contexts.
Step 9: Test the RBAC Layer
Manual checks with curl
# 1. Log in as each persona
curl -s -X POST http://localhost:3000/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"[email protected]","password":"Secret123!"}'
# 2. Editor creates a post -> 201
curl -i -X POST http://localhost:3000/api/posts \
-H "Authorization: Bearer $EDITOR_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"title":"RBAC in Node.js"}'
# 3. Editor deletes a post -> 403 FORBIDDEN
curl -i -X DELETE http://localhost:3000/api/posts/42 \
-H "Authorization: Bearer $EDITOR_TOKEN"
# 4. Plain user creates a post -> 403 FORBIDDEN
curl -i -X POST http://localhost:3000/api/posts \
-H "Authorization: Bearer $USER_TOKEN" -d '{}'
# 5. Tampered token -> 401 INVALID_TOKEN
curl -i http://localhost:3000/api/admin/users \
-H "Authorization: Bearer $USER_TOKEN.tampered"
Automated tests with Jest and Supertest
describe('RBAC on /api/posts', () => {
const cases = [
{ role: 'user', method: 'post', path: '/api/posts', expected: 403 },
{ role: 'editor', method: 'post', path: '/api/posts', expected: 201 },
{ role: 'editor', method: 'delete', path: '/api/posts/1', expected: 403 },
{ role: 'admin', method: 'delete', path: '/api/posts/1', expected: 204 },
{ role: 'anon', method: 'post', path: '/api/posts', expected: 401 }
];
test.each(cases)('$role $method $path -> $expected', async (c) => {
const req = request(app)[c.method](c.path);
if (c.role !== 'anon') req.set('Authorization', `Bearer ${tokens[c.role]}`);
const res = await req.send({ title: 'test' });
expect(res.status).toBe(c.expected);
});
});
A table-driven test like this is the single highest-value test in an authorization codebase. Add one row every time you add a route.

The 8 Most Common RBAC Mistakes in Node.js
1. Enforcing permissions only in the frontend
Hiding a “Delete” button in React is user experience, not security. Anyone can open DevTools, copy the bearer token and call DELETE /api/posts/42 directly with curl. The frontend decides what to display; the API decides what is allowed. Both layers read the same permission list, but only the server enforces it.
2. Trusting a role sent in the request body
Never do if (req.body.role === 'admin') and never accept a role field on registration or profile update. Strip it server-side with an explicit allow-list of updatable fields. Privilege escalation through mass assignment is one of the most exploited API flaws.
3. Decoding the JWT instead of verifying it
jwt.decode() does not check the signature. Only jwt.verify() with a pinned algorithm does. A payload that says roles: ['admin'] means nothing without a valid signature.
4. Checking authorization inside the controller
Scattering if (user.role !== 'admin') through controllers guarantees that someone will forget it on the next endpoint. Guards belong in middleware, declared at the route, visible in one glance.
5. Forgetting object-level authorization
Route-level RBAC answers “can this role update posts”. It does not answer “can this user update post 42“. Without the ownership middleware, any editor can edit any other editor’s content by changing an ID.
6. Long-lived tokens with no revocation path
A 30-day access token holding roles: ['admin'] keeps admin rights for 30 days after the demotion. Short access tokens plus a tokenVersion counter fix this in a few lines.
7. Hard-coding roles across the codebase
String literals such as 'admin' sprinkled in twenty files make renaming impossible. Export constants and, better, check permissions rather than roles.
8. No audit trail
Log every denied request and every role change with the actor, target, action and timestamp. When something goes wrong, this table is what saves your incident review.
Should You Use a Library Instead?
The middleware above is roughly 80 lines and covers the vast majority of REST APIs. Reach for a library when your rules stop being flat:
| Option | Best for | Trade-off |
|---|---|---|
| Custom middleware (this guide) | Flat roles, clear permission matrix | You own the edge cases |
| CASL | Conditional rules shared between API and frontend | Learning curve on ability definitions |
| accesscontrol | Role inheritance plus attribute filtering | In-memory grants, less dynamic |
| Casbin | Policy files, RBAC plus ABAC combos | Extra model and policy concepts |
| Authorization service (Oso, Permify, OpenFGA) | Relationship-based rules across microservices | Another service to operate |
Rule of thumb: start with explicit middleware, migrate to a policy engine when a permission depends on more than a role and an owner ID.
Production Checklist
- Access tokens expire in 15 minutes or less; refresh tokens rotate and are stored hashed.
jwt.verify()pins the algorithm, issuer and audience.- Every non-public route has an explicit guard; there is a test proving unauthenticated access returns 401.
- Every write route that touches a record has an ownership or tenant check.
- The role and permission tables are seeded by migration, not edited manually in production.
- Role changes bump
tokenVersionand are written to an audit log. - Secrets live in environment variables or a secret manager, never in the repository.
- Rate limiting is applied to login and refresh endpoints.
- 403 responses are logged with user ID, route and required permission.
- A table-driven test file covers the full role by route matrix.
FAQ
What is role based access control in Node.js?
It is an authorization model where permissions are attached to roles and roles are attached to users. In a Node.js Express API it is normally implemented as middleware that reads the roles or permissions from a verified JWT and returns 403 before the controller executes.
Should I store roles in the JWT or query the database on each request?
Store them in the JWT for speed, keep the token short-lived, and add a tokenVersion claim so you can invalidate tokens instantly when a role changes. Query the database directly only for high-risk operations such as payments or account deletion.
What is the difference between requireRole and requirePermission?
requireRole checks a job title, requirePermission checks a capability. Permission checks are more maintainable because adding a new role only means updating seed data, not editing route files.
How do I handle a user with several roles?
Use a many-to-many user_roles table, merge all permissions into a set when signing the token, and take the highest level for hierarchy checks. Union of permissions is the standard behavior; if you need explicit denials, move to a policy engine.
Is checking permissions in React or Vue enough?
No. Frontend checks only improve the interface. Any client can call your endpoints directly with a valid token, so the API must re-verify every permission server side.
What status code should an RBAC failure return?
Use 401 when the identity is missing or invalid and 403 when the identity is valid but lacks rights. Return 404 instead of 403 when revealing the existence of the resource would leak information. freecodecamp.org goes into the numbers.
How do I add a new role later without breaking anything?
Insert the role with its level, attach permissions in role_permissions, and add a row to your test matrix. Because routes check permissions rather than role names, no route file needs to change.
Wrapping Up
A solid RBAC layer in Node.js comes down to four disciplines: a normalized roles and permissions schema, a JWT payload designed for authorization with a revocation counter, declarative Express middleware placed before the controller, and ownership checks for row-level rules. Add the table-driven test matrix and you have a permission system that survives new roles, new endpoints and new developers.
Need a second pair of eyes on your API security or a full authorization audit? The engineering team at Santiance can help you review and harden your Node.js stack.
