How to Implement Role-Based Access Control in a Node.js REST API with JWT — Opportunihub
Course Remote

How to Implement Role-Based Access Control in a Node.js REST API with JWT

Zia Ullah · Remote

At a glance

Type
Course
Organisation
Zia Ullah
Location
Remote
Work mode
Remote
Deadline
Rolling / not stated
Posted
9 Jul 2026

About this course

<p>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.</p> <p>Role-Based Access Control sounds fancy, but the idea is simple: what you can do depends on <em>who you are</em>, not just <em>that you're logged in</em>. An admin deletes users. An editor creates posts. A regular user just reads. Same app, completely different experience depending on who's asking.</p> <p>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.</p> <p>By the end, you'll have three working roles (<code>admin</code>, <code>editor</code>, <code>user</code>) 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.</p> <p><strong>Full source code on GitHub:</strong> <a href="https://github.com/ziaongit/nodejs-rbac-jwt-api">github.com/ziaongit/nodejs-rbac-jwt-api</a></p> <h2 id="heading-table-of-contents">Table of Contents</h2> <ul> <li><p><a href="#heading-what-youll-learn">What You'll Learn</a></p> </li> <li><p><a href="#heading-prerequisites">Prerequisites</a></p> </li> <li><p><a href="#heading-what-well-build">What We'll Build</a></p> </li> <li><p><a href="#heading-project-setup">Project Setup</a></p> </li> <li><p><a href="#heading-setting-up-the-in-memory-data-store">Setting Up the In-Memory Data Store</a></p> </li> <li><p><a href="#heading-building-the-auth-routes">Building the Auth Routes</a></p> </li> <li><p><a href="#heading-building-the-rbac-middleware">Building the RBAC Middleware</a></p> </li> <li><p><a href="#heading-building-the-protected-routes">Building the Protected Routes</a></p> </li> <li><p><a href="#heading-putting-it-all-together">Putting It All Together</a></p> </li> <li><p><a href="#heading-testing-the-api">Testing the API</a></p> </li> <li><p><a href="#heading-key-takeaways">Key Takeaways</a></p> </li> <li><p><a href="#heading-conclusion">Conclusion</a></p> </li> </ul> <h2 id="heading-what-youll-learn">What You'll Learn</h2> <ul> <li><p>What RBAC is and how it differs from basic authentication</p> </li> <li><p>How to embed roles in JWT payloads</p> </li> <li><p>How to write reusable Express middleware for token verification and role checking</p> </li> <li><p>How to protect API routes based on user roles</p> </li> </ul> <h2 id="heading-prerequisites">Prerequisites</h2> <ul> <li><p>Node.js (v18+) installed</p> </li> <li><p>Basic knowledge of Express.js</p> </li> <li><p>Familiarity with how JWTs work (we'll cover the relevant parts)</p> </li> <li><p>npm installed</p> </li> </ul> <h2 id="heading-what-well-build">What We'll Build</h2> <p>We'll build a REST API for a simple content management system with three user roles:</p> <table> <thead> <tr> <th>Role</th> <th>Permissions</th> </tr> </thead> <tbody><tr> <td><code>user</code></td> <td>Read content</td> </tr> <tr> <td><code>editor</code></td> <td>Read + create content</td> </tr> <tr> <td><code>admin</code></td> <td>Full access — read, create, delete content, manage users</td> </tr> </tbody></table> <p>The API will expose these endpoints:</p> <table> <thead> <tr> <th>Method</th> <th>Endpoint</th> <th>Access</th> </tr> </thead> <tbody><tr> <td>POST</td> <td>/api/auth/register</td> <td>Public</td> </tr> <tr> <td>POST</td> <td>/api/auth/login</td> <td>Public</td> </tr> <tr> <td>GET</td> <td>/api/content</td> <td>user, editor, admin</td> </tr> <tr> <td>POST</td> <td>/api/content</td> <td>editor, admin</td> </tr> <tr> <td>DELETE</td> <td>/api/content/:id</td> <td>admin only</td> </tr> <tr> <td>GET</td> <td>/api/admin/users</td> <td>admin only</td> </tr> </tbody></table> <h2 id="heading-project-setup">Project Setup</h2> <p>Create a new folder and initialize the project:</p> <pre><code class="language-bash">mkdir nodejs-rbac-jwt-api cd nodejs-rbac-jwt-api npm init -y </code></pre> <p>Install the dependencies:</p> <pre><code class="language-bash">npm install express jsonwebtoken bcryptjs dotenv npm install --save-dev nodemon </code></pre> <p>Here's what each package does:</p> <ul> <li><p><strong>express</strong>: web framework for building the API</p> </li> <li><p><strong>jsonwebtoken</strong>: creates and verifies JWTs</p> </li> <li><p><strong>bcryptjs</strong>: securely hashes passwords</p> </li> <li><p><strong>dotenv</strong>: reads your <code>.env</code> file so you're not hardcoding secrets in your source code</p> </li> </ul> <p>Update <code>package.json</code> to add start scripts:</p> <pre><code class="language-json">"scripts": { "start": "node src/app.js", "dev": "nodemon src/app.js" } </code></pre> <p>Create the project structure:</p> <pre><code class="language-plaintext">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 </code></pre> <p>Create your <code>.env</code> file:</p> <pre><code class="language-plaintext">JWT_SECRET=your_super_secret_key_change_this_in_production PORT=3000 </code></pre> <p><strong>Important:</strong> Never commit your <code>.env</code> file to version control. Add it to <code>.gitignore</code>.</p> <h2 id="heading-setting-up-the-in-memory-data-store">Setting Up the In-Memory Data Store</h2> <p>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.</p> <p>Create <code>src/data/users.js</code>:</p> <pre><code class="language-javascript">// 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 }; </code></pre> <p>One thing worth noting: <code>getAllUsers</code> uses destructuring to drop the password before returning anything. Never send password fields in API responses, even hashed ones.</p> <h2 id="heading-building-the-auth-routes">Building the Auth Routes</h2> <p>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.</p> <p>Create <code>src/routes/auth.js</code>:</p> <pre><code class="language-javascript">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; </code></pre> <p>The most important line is the JWT payload:</p> <pre><code class="language-javascript">jwt.sign({ id, email, role }, process.env.JWT_SECRET, { expiresIn: '24h' }) </code></pre> <p>By embedding <code>role</code> 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.</p> <h2 id="heading-building-the-rbac-middleware">Building the RBAC Middleware</h2> <p>This is the core of the system. We need two separate middleware functions:</p> <ol> <li><p><code>verifyToken</code> confirms the JWT is valid and attaches the decoded payload to <code>req.user</code></p> </li> <li><p><code>checkRole</code> confirms the user has the required role for a specific route</p> </li> </ol> <p>Keeping them separate gives you flexibility. Some routes only need authentication. Others need both authentication and a specific role.</p> <p>Create <code>src/middleware/auth.js</code>:</p> <pre><code class="language-javascript">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 }; </code></pre> <p><code>checkRole</code> uses a rest parameter (<code>...allowedRoles</code>) so you can pass in one or multiple roles:</p> <pre><code class="language-javascript">checkRole('admin') // only admin checkRole('editor', 'admin') // editor or admin checkRole('user', 'editor', 'admin') // all roles </code></pre> <p>This makes route definitions clean and readable — the permissions are visible right at the route level.</p> <h2 id="heading-building-the-protected-routes">Building the Protected Routes</h2> <p>Now let's wire up routes that use the middleware.</p> <p>Create <code>src/routes/content.js</code>:</p> <pre><code class="language-javascript">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; </code></pre> <p>Notice how readable each route is:</p> <pre><code class="language-javascript">router.delete('/:id', verifyToken, checkRole('admin'), handler) </code></pre> <p>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.</p> <p>Create <code>src/routes/admin.js</code>:</p> <pre><code class="language-javascript">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; </code></pre> <h2 id="heading-putting-it-all-together">Putting It All Together</h2> <p>Create <code>src/app.js</code>:</p> <pre><code class="language-javascript">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}`); }); </code></pre> <h2 id="heading-testing-the-api">Testing the API</h2> <p>Start the server:</p> <pre><code class="language-bash">npm run dev </code></pre> <h3 id="heading-step-1-register-users-with-different-roles">Step 1: Register Users with Different Roles</h3> <p>Register an admin:</p> <pre><code class="language-bash">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"}' </code></pre> <p>Register an editor:</p> <pre><code class="language-bash">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"}' </code></pre> <p>Register a regular user (no role specified — defaults to <code>user</code>):</p> <pre><code class="language-bash">curl -X POST http://localhost:3000/api/auth/register \ -H "Content-Type: application/json" \ -d '{"name": "Regular User", "email": "user@example.com", "password": "password123"}' </code></pre> <h3 id="heading-step-2-log-in-and-get-a-token">Step 2: Log in and Get a Token</h3> <pre><code class="language-bash">curl -X POST http://localhost:3000/api/auth/login \ -H "Content-Type: application/json" \ -d '{"email": "user@example.com", "password": "password123"}' </code></pre> <p>You'll get a response like:</p> <pre><code class="language-json">{ "message": "Login successful", "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." } </code></pre> <p>Copy the token.</p> <h3 id="heading-step-3-test-role-based-access">Step 3: Test Role-based Access</h3> <p><strong>Read content as a regular user (should succeed):</strong></p> <pre><code class="language-bash">curl http://localhost:3000/api/content \ -H "Authorization: Bearer YOUR_TOKEN_HERE" </code></pre> <p><strong>Try creating content as a regular user (should fail — 403):</strong></p> <pre><code class="language-bash">curl -X POST http://localhost:3000/api/content \ -H "Authorization: Bearer YOUR_TOKEN_HERE" \ -H "Content-Type: application/json" \ -d '{"title": "New Article"}' </code></pre> <p>Response:</p> <pre><code class="language-json">{ "message": "Access denied. Required role: editor or admin. Your role: user" } </code></pre> <p>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.</p> <h3 id="heading-step-4-decode-the-jwt-to-see-the-role">Step 4: Decode the JWT to See the Role</h3> <p>You can paste any token into <a href="https://jwt.io">jwt.io</a> to inspect the payload. You'll see something like:</p> <pre><code class="language-json">{ "id": "1720300000000", "email": "admin@example.com", "role": "admin", "iat": 1720300000, "exp": 1720386400 } </code></pre> <p>The <code>role</code> field is exactly what <code>checkRole</code> reads on every protected request.</p> <h2 id="heading-key-takeaways">Key Takeaways</h2> <p>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.</p> <p>Middleware is composable. <code>verifyToken</code> and <code>checkRole</code> are separate, reusable functions. You can chain them on any route in any combination.</p> <p>Permissions are visible at the route level. <code>router.delete('/:id', verifyToken, checkRole('admin'), handler)</code> tells you everything about access control before you even read the handler.</p> <p><strong>Before you ship this to production:</strong></p> <ul> <li><p>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.</p> </li> <li><p>That 24h token expiry is too long. Cut it to 15 minutes and add refresh token rotation. A stolen token becomes useless fast.</p> </li> <li><p>Re-validate roles from the DB on sensitive operations. A role change won't reflect in an existing token until it expires</p> </li> <li><p>HTTPS, always</p> </li> <li><p>If your permission logic grows beyond "check a role", look at <a href="https://casl.js.org/">casl</a>. It handles attribute-level rules cleanly</p> </li> </ul> <h2 id="heading-conclusion">Conclusion</h2> <p>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.</p> <p><strong>Full source code on GitHub:</strong> <a href="https://github.com/ziaongit/nodejs-rbac-jwt-api">github.com/ziaongit/nodejs-rbac-jwt-api</a></p>

How to apply

  1. 1 Read the full details above and confirm you meet the eligibility criteria.
  2. 2 Prepare your documents — an updated CV, and any cover letter, proposal or certificates required.
  3. 3 Click Apply on official site to complete your application on Zia Ullah’s official page.
  4. 4 Submit as early as possible — many close once filled.
Apply on official site

Sourced from freecodecamp. Always verify details on the official website. Opportunihub never charges you to apply.

Frequently asked questions

How do I apply for How to Implement Role-Based Access Control in a Node.js REST API with JWT?

Review the full details and eligibility on this page, prepare your documents, then use the “Apply on official site” button to complete your application on Zia Ullah’s official page.

Is this opportunity remote or location-based?

This opportunity is remote-friendly and open to applicants who can work from anywhere.

Is How to Implement Role-Based Access Control in a Node.js REST API with JWT free to apply for?

Opportunihub lists this Course for free. Legitimate Courses do not ask for payment to apply — never pay a fee to submit an application.