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

  • 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:

RolePermissions
userRead content
editorRead + create content
adminFull access — read, create, delete content, manage users

The API will expose these endpoints:

MethodEndpointAccess
POST/api/auth/registerPublic
POST/api/auth/loginPublic
GET/api/contentuser, editor, admin
POST/api/contenteditor, admin
DELETE/api/content/:idadmin only
GET/api/admin/usersadmin 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 is 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) => users.find((u) => u.email === email);
const findUserById = (id) => users.find((u) => u.id === id);
const createUser = (user) => {
  users.push(user);
  return user;
};
const getAllUsers = () => users.map(({ password, ...user }) => 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) => {
  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) => {
  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:

  1. verifyToken confirms the JWT is valid and attaches the decoded payload to req.user
  2. 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) => {
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1]; // Expects: Bearer <token>

  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) => {
  return (req, res, next) => {
    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 one or more roles to a single route:

checkRole('admin')              // admin only
checkRole('editor', 'admin')    // editor or admin
checkRole('user', 'editor', 'admin')  // any authenticated role

Building the Protected Routes

Content Routes

Create src/routes/content.js:

const express = require('express');
const { verifyToken, checkRole } = require('../middleware/auth');

const router = express.Router();

// In-memory content store
let content = [
  { id: '1', title: 'Getting Started with Node.js', body: 'Node.js is a runtime...', author: 'system' },
  { id: '2', title: 'Understanding JWT', body: 'JSON Web Tokens are...', author: 'system' },
];

// GET /api/content — accessible by all authenticated users
router.get('/', verifyToken, checkRole('user', 'editor', 'admin'), (req, res) => {
  res.json({
    message: 'Content retrieved successfully',
    user: { id: req.user.id, role: req.user.role },
    content,
  });
});

// POST /api/content — accessible by editor and admin only
router.post('/', verifyToken, checkRole('editor', 'admin'), (req, res) => {
  const { title, body } = req.body;

  if (!title || !body) {
    return res.status(400).json({ message: 'Title and body are required' });
  }

  const newContent = {
    id: Date.now().toString(),
    title,
    body,
    author: req.user.email,
  };

  content.push(newContent);

  res.status(201).json({
    message: 'Content created successfully',
    content: newContent,
  });
});

// DELETE /api/content/:id — admin only
router.delete('/:id', verifyToken, checkRole('admin'), (req, res) => {
  const { id } = req.params;
  const index = content.findIndex((c) => c.id === id);

  if (index === -1) {
    return res.status(404).json({ message: 'Content not found' });
  }

  const deleted = content.splice(index, 1)[0];

  res.json({
    message: 'Content deleted successfully',
    deleted,
  });
});

module.exports = router;

Admin Routes

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) => {
  const users = getAllUsers();

  res.json({
    message: 'Users retrieved successfully',
    count: users.length,
    users,
  });
});

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('/health', (req, res) => {
  res.json({ status: 'OK', timestamp: new Date().toISOString() });
});

// 404 handler
app.use('*', (req, res) => {
  res.status(404).json({ message: 'Route not found' });
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

module.exports = app;

Start the server:

npm run dev

Testing the API

Register Users

Register a regular user:

curl -X POST http://localhost:3000/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"name": "John User", "email": "user@example.com", "password": "password123", "role": "user"}'

Register an editor:

curl -X POST http://localhost:3000/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"name": "Jane Editor", "email": "editor@example.com", "password": "password123", "role": "editor"}'

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"}'

Log In and Get a Token

curl -X POST http://localhost:3000/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email": "admin@example.com", "password": "password123"}'

Copy the token from the response and use it in subsequent requests.

Test Protected Endpoints

Read content (any authenticated role):

curl -X GET http://localhost:3000/api/content \
  -H "Authorization: Bearer <your-token>"

Create content (editor or admin):

curl -X POST http://localhost:3000/api/content \
  -H "Authorization: Bearer <your-token>" \
  -H "Content-Type: application/json" \
  -d '{"title": "New Article", "body": "Article content here"}'

Delete content (admin only):

curl -X DELETE http://localhost:3000/api/content/1 \
  -H "Authorization: Bearer <your-token>"

List all users (admin only):

curl -X GET http://localhost:3000/api/admin/users \
  -H "Authorization: Bearer <your-token>"

Expected Responses by Role

When a user tries to delete content, they’ll get:

{
  "message": "Access denied. Required role: admin. Your role: user"
}

When a user tries to create content, they’ll get:

{
  "message": "Access denied. Required role: editor or admin. Your role: user"
}

When no token is provided:

{
  "message": "Access denied. No token provided."
}

Key Takeaways

  • Separation of concerns: verifyToken handles authentication, checkRole handles authorization. Each does one thing.
  • No per-request DB lookups: The role is embedded in the JWT. Once verified, the server has everything it needs.
  • Composable middleware: Stack verifyToken and checkRole on any route in any combination.
  • Clear error messages: The 403 response tells the client exactly what role was required and what role they have — useful during development.
  • Passwords never leave the server: The getAllUsers function strips the password field before any response is sent.

Conclusion

You now have a working RBAC system built on two focused middleware functions and a JWT payload that carries the user’s role. The pattern scales cleanly: adding a new role means updating the validRoles array and adjusting which roles you pass to checkRole on the relevant routes. No restructuring required. For production use, replace the in-memory user store with a real database and store your JWT secret securely — everything else in this implementation carries over directly.