---
title: "Implement Role-Based Access Control in Node.js REST API"
url: https://stacklist.com/card/2325434f-f53d-4a49-b81c-b667fab8509a
source_url: "https://www.freecodecamp.org/news/role-based-access-control-nodejs-rest-api-jwt/"
stack: https://stacklist.com/stack/67b59b54-8aa2-4460-bdb8-fa13669f687d
summary: "Role-Based Access Control in Node.js REST API with JWT is a tutorial that teaches how to implement RBAC using JWT tokens and Express middleware to control user access based on roles (admin, editor, user). The guide covers authentication setup, middleware creation, and protecting API routes with practical examples and a complete project structure."
tags: "rbac, jwt, node.js, express, authentication, middleware, rest-api"
key_entities: "Zia Ullah (person), Node.js (technology), Express.js (technology), JWT (technology), bcryptjs (technology), jsonwebtoken (technology), Role-Based Access Control (concept), REST API (concept), GitHub (organization)"
classification: "tutorial"
content_hash: "sha256:ea1b7f99a1e33cfaff6f293858b9758293b57f584f762970ef633672251487e2"
acp_version: "0.2"
token_counts_approximate: 3856
visibility: public
agent_accessible: true
status: "final"
---

# Implement Role-Based Access Control in Node.js REST API

Zia Ullah The first time I built an API without thinking about roles, I gave every logged-in user the same access. It worked fine until a regular user accidentally hit a delete endpoint and wiped test data. That was the day I actually sat down and learned RBAC properly. Role-Based Access Control sounds fancy, but the idea is simple: what you can do depends on who you are , not just that you're logged in . An admin deletes users. An editor creates posts. A regular user just reads. Same app, completely different experience depending on who's asking. That's what we're building here. A REST API with three roles: JWT to carry those roles on every request, and a pair of middleware functions that check permissions before your route handlers even run. There's no database hit per request, and no if/else soup in your business logic. By the end, you'll have three working roles ( admin , editor , user ) each locked to their own endpoints. More importantly, the pattern is transferable: once it clicks, you'll wire it into your next project without needing a tutorial. Full source code on GitHub: github.com/ziaongit/nodejs-rbac-jwt-api Table of Contents What You'll Learn Prerequisites What We'll Build Project Setup Setting Up the In-Memory Data Store Building the Auth Routes Building the RBAC Middleware Building the Protected Routes Putting It All Together Testing the API Key Takeaways Conclusion What You'll Learn What RBAC is and how it differs from basic authentication How to embed roles in JWT payloads How to write reusable Express middleware for token verification and role checking How to protect API routes based on user roles Prerequisites Node.js (v18+) installed Basic knowledge of Express.js Familiarity with how JWTs work (we'll cover the relevant parts) npm installed What We'll Build We'll build a REST API for a simple content management system with three user roles: Role Permissions user Read content editor Read + create content admin Full access — read, create, delete content, manage users The API will expose these endpoints: Method Endpoint Access POST /api/auth/register Public POST /api/auth/login Public GET /api/content user, editor, admin POST /api/content editor, admin DELETE /api/content/:id admin only GET /api/admin/users admin only Project Setup Create a new folder and initialize the project: mkdir nodejs-rbac-jwt-api cd nodejs-rbac-jwt-api npm init -y Install the dependencies: npm install express jsonwebtoken bcryptjs dotenv npm install --save-dev nodemon Here's what each package does: express : web framework for building the API jsonwebtoken : creates and verifies JWTs bcryptjs : securely hashes passwords dotenv : reads your .env file so you're not hardcoding secrets in your source code Update package.json to add start scripts: "scripts": { "start": "node src/app.js", "dev": "nodemon src/app.js" } Create the project structure: nodejs-rbac-jwt-api/ ├── src/ │ ├── middleware/ │ │ └── auth.js │ ├── routes/ │ │ ├── auth.js │ │ ├── content.js │ │ └── admin.js │ ├── data/ │ │ └── users.js │ └── app.js ├── .env ├── .env.example └── package.json Create your .env file: JWT_SECRET=your_super_secret_key_change_this_in_production PORT=3000 Important: Never commit your .env file to version control. Add it to .gitignore . Setting Up the In-Memory Data Store We don't have a database here, just an array in memory. The point was to keep the focus on RBAC, not spend half the tutorial on database config. In a real project, swap the array for whatever database you're already using. Create src/data/users.js : // In-memory users store // In production, replace this with a real database (MongoDB, PostgreSQL, etc.) const users = []; const findUserByEmail = (email) =&gt; users.find((u) =&gt; u.email === email); const findUserById = (id) =&gt; users.find((u) =&gt; u.id === id); const createUser = (user) =&gt; { users.push(user); return user; }; const getAllUsers = () =&gt; users.map(({ password, ...user }) =&gt; user); module.exports = { findUserByEmail, findUserById, createUser, getAllUsers }; One thing worth noting: getAllUsers uses destructuring to drop the password before returning anything. Never send password fields in API responses, even hashed ones. Building the Auth Routes The auth routes handle registration and login. Login is where roles first enter the picture — we embed the user's role directly into the JWT payload. Create src/routes/auth.js : const express = require('express'); const bcrypt = require('bcryptjs'); const jwt = require('jsonwebtoken'); const { findUserByEmail, createUser } = require('../data/users'); const router = express.Router(); // POST /api/auth/register router.post('/register', async (req, res) =&gt; { const { name, email, password, role } = req.body; if (!name || !email || !password) { return res.status(400).json({ message: 'Name, email, and password are required' }); } if (findUserByEmail(email)) { return res.status(409).json({ message: 'Email already registered' }); } // Only allow valid roles — default to 'user' if none provided const validRoles = ['user', 'editor', 'admin']; const assignedRole = validRoles.includes(role) ? role : 'user'; const hashedPassword = await bcrypt.hash(password, 10); const newUser = { id: Date.now().toString(), name, email, password: hashedPassword, role: assignedRole, }; createUser(newUser); res.status(201).json({ message: 'User registered successfully', user: { id: newUser.id, name: newUser.name, email: newUser.email, role: newUser.role, }, }); }); // POST /api/auth/login router.post('/login', async (req, res) =&gt; { const { email, password } = req.body; if (!email || !password) { return res.status(400).json({ message: 'Email and password are required' }); } const user = findUserByEmail(email); if (!user) { return res.status(401).json({ message: 'Invalid credentials' }); } const isMatch = await bcrypt.compare(password, user.password); if (!isMatch) { return res.status(401).json({ message: 'Invalid credentials' }); } // Issue JWT — embed role in the payload const token = jwt.sign( { id: user.id, email: user.email, role: user.role, // ← This is the key part for RBAC }, process.env.JWT_SECRET, { expiresIn: '24h' } ); res.json({ message: 'Login successful', token, }); }); module.exports = router; The most important line is the JWT payload: jwt.sign({ id, email, role }, process.env.JWT_SECRET, { expiresIn: '24h' }) By embedding role in the token, every subsequent request carries the user's permissions without requiring a database lookup. The server just verifies the token and reads the role from the payload. Building the RBAC Middleware This is the core of the system. We need two separate middleware functions: verifyToken confirms the JWT is valid and attaches the decoded payload to req.user checkRole confirms the user has the required role for a specific route Keeping them separate gives you flexibility. Some routes only need authentication. Others need both authentication and a specific role. Create src/middleware/auth.js : const jwt = require('jsonwebtoken'); // Middleware 1: Verify the JWT token const verifyToken = (req, res, next) =&gt; { const authHeader = req.headers['authorization']; const token = authHeader &amp;&amp; authHeader.split(' ')[1]; // Expects: Bearer &lt;token&gt; if (!token) { return res.status(401).json({ message: 'Access denied. No token provided.' }); } try { const decoded = jwt.verify(token, process.env.JWT_SECRET); req.user = decoded; // Attach decoded payload (including role) to request next(); } catch (err) { return res.status(403).json({ message: 'Invalid or expired token.' }); } }; // Middleware 2: Check if user has one of the required roles const checkRole = (...allowedRoles) =&gt; { return (req, res, next) =&gt; { if (!req.user) { return res.status(401).json({ message: 'Not authenticated.' }); } if (!allowedRoles.includes(req.user.role)) { return res.status(403).json({ message: `Access denied. Required role: ${allowedRoles.join(' or ')}. Your role: ${req.user.role}`, }); } next(); }; }; module.exports = { verifyToken, checkRole }; checkRole uses a rest parameter ( ...allowedRoles ) so you can pass in one or multiple roles: checkRole('admin') // only admin checkRole('editor', 'admin') // editor or admin checkRole('user', 'editor', 'admin') // all roles This makes route definitions clean and readable — the permissions are visible right at the route level. Building the Protected Routes Now let's wire up routes that use the middleware. Create src/routes/content.js : const express = require('express'); const { verifyToken, checkRole } = require('../middleware/auth'); const router = express.Router(); // In-memory content store const content = [ { id: '1', title: 'Getting Started with Node.js', author: 'admin' }, { id: '2', title: 'Express Middleware Explained', author: 'editor' }, ]; // GET /api/content — all authenticated users router.get('/', verifyToken, checkRole('user', 'editor', 'admin'), (req, res) =&gt; { res.json({ content }); }); // POST /api/content — editors and admins only router.post('/', verifyToken, checkRole('editor', 'admin'), (req, res) =&gt; { const { title } = req.body; if (!title) { return res.status(400).json({ message: 'Title is required' }); } const newItem = { id: Date.now().toString(), title, author: req.user.email, }; content.push(newItem); res.status(201).json({ message: 'Content created', item: newItem }); }); // DELETE /api/content/:id — admin only router.delete('/:id', verifyToken, checkRole('admin'), (req, res) =&gt; { const index = content.findIndex((c) =&gt; c.id === req.params.id); if (index === -1) { return res.status(404).json({ message: 'Content not found' }); } content.splice(index, 1); res.json({ message: 'Content deleted successfully' }); }); module.exports = router; Notice how readable each route is: router.delete('/:id', verifyToken, checkRole('admin'), handler) You can understand the access control without reading the handler body. This is one of the key advantages of middleware-based RBAC: permissions live at the routing layer, not buried in business logic. Create src/routes/admin.js : const express = require('express'); const { verifyToken, checkRole } = require('../middleware/auth'); const { getAllUsers } = require('../data/users'); const router = express.Router(); // GET /api/admin/users — admin only router.get('/users', verifyToken, checkRole('admin'), (req, res) =&gt; { res.json({ users: getAllUsers() }); }); module.exports = router; Putting It All Together Create src/app.js : require('dotenv').config(); const express = require('express'); const authRoutes = require('./routes/auth'); const contentRoutes = require('./routes/content'); const adminRoutes = require('./routes/admin'); const app = express(); app.use(express.json()); // Routes app.use('/api/auth', authRoutes); app.use('/api/content', contentRoutes); app.use('/api/admin', adminRoutes); // Health check app.get('/', (req, res) =&gt; { res.json({ message: 'RBAC API is running' }); }); const PORT = process.env.PORT || 3000; app.listen(PORT, () =&gt; { console.log(`Server running on port ${PORT}`); }); Testing the API Start the server: npm run dev Step 1: Register Users with Different Roles Register an admin: curl -X POST http://localhost:3000/api/auth/register \ -H "Content-Type: application/json" \ -d '{"name": "Admin User", "email": "admin@example.com", "password": "password123", "role": "admin"}' Register an editor: curl -X POST http://localhost:3000/api/auth/register \ -H "Content-Type: application/json" \ -d '{"name": "Editor User", "email": "editor@example.com", "password": "password123", "role": "editor"}' Register a regular user (no role specified — defaults to user ): curl -X POST http://localhost:3000/api/auth/register \ -H "Content-Type: application/json" \ -d '{"name": "Regular User", "email": "user@example.com", "password": "password123"}' Step 2: Log in and Get a Token curl -X POST http://localhost:3000/api/auth/login \ -H "Content-Type: application/json" \ -d '{"email": "user@example.com", "password": "password123"}' You'll get a response like: { "message": "Login successful", "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." } Copy the token. Step 3: Test Role-based Access Read content as a regular user (should succeed): curl http://localhost:3000/api/content \ -H "Authorization: Bearer YOUR_TOKEN_HERE" Try creating content as a regular user (should fail — 403): curl -X POST http://localhost:3000/api/content \ -H "Authorization: Bearer YOUR_TOKEN_HERE" \ -H "Content-Type: application/json" \ -d '{"title": "New Article"}' Response: { "message": "Access denied. Required role: editor or admin. Your role: user" } Now log in as an editor and try the same POST request. It succeeds. Log in as admin and try the DELETE route. Only the admin token will work. Step 4: Decode the JWT to See the Role You can paste any token into jwt.io to inspect the payload. You'll see something like: { "id": "1720300000000", "email": "admin@example.com", "role": "admin", "iat": 1720300000, "exp": 1720386400 } The role field is exactly what checkRole reads on every protected request. Key Takeaways Roles live in the JWT payload. The role travels with the token — no extra DB call needed every time someone hits a protected route. It gets embedded at login and verified cryptographically on each request. Middleware is composable. verifyToken and checkRole are separate, reusable functions. You can chain them on any route in any combination. Permissions are visible at the route level. router.delete('/:id', verifyToken, checkRole('admin'), handler) tells you everything about access control before you even read the handler. Before you ship this to production: The in-memory array was just to keep this tutorial focused — replace it with a real database before anything goes near production. A server restart wipes all your users right now. That 24h token expiry is too long. Cut it to 15 minutes and add refresh token rotation. A stolen token becomes useless fast. Re-validate roles from the DB on sensitive operations. A role change won't reflect in an existing token until it expires HTTPS, always If your permission logic grows beyond "check a role", look at casl . It handles attribute-level rules cleanly Conclusion The core of it fits in two middleware functions and a JWT payload. I've used this same pattern across several projects. And once you've built it yourself, you'll start spotting it everywhere, because almost every multi-user app needs some version of it. Full source code on GitHub: github.com/ziaongit/nodejs-rbac-jwt-api Zia Ullah Zia Ullah is a Full Stack Developer at ValueAdd Solution Scandinavia AB (www.valueadd.se), Sweden, with a Master&#39;s in Software Engineering and a Bachelor&#39;s in Computer Science. He has 13+ years of experience building and deploying applications on Azure, configuring CI/CD pipelines with GitHub Actions and Azure DevOps, and implementing security controls across healthcare, logistics, and SaaS projects. He is also a technical writer whose work has been published on freeCodeCamp, DevOps.com, Dev.to, and HackerNoon, where he shares practical insights on web development, cloud computing, DevOps, and software engineering. LinkedIn: www.linkedin.com/in/zia-ullah/ If this article was helpful, share it . Learn to code for free. freeCodeCamp's open source curriculum has helped more than 40,000 people get jobs as developers. Get started ADVERTISEMENT
