How to Implement Role Based Access Control in a Node.js Web App

If you’re building a Node.js API that serves more than a handful of users, sooner or later you’ll need to answer one critical question: who is allowed to do what? That’s exactly what role based access control (RBAC) in Node.js solves. In this practical tutorial, we’ll build a working RBAC system in Express, complete with roles, permissions, middleware guards and a clean database schema you can drop straight into your project.

No abstract theory, no vendor lock-in. Just code you can copy, adapt and ship.

What is Role Based Access Control (and Why Node.js Devs Need It)

Role Based Access Control is an authorization pattern where permissions are attached to roles, and roles are assigned to users. Instead of writing endless if (user.email === 'admin@...') checks scattered across your codebase, you centralize access rules in one place.

A typical setup looks like this:

  • Users get one or more roles (e.g. admin, editor, viewer).
  • Roles contain a set of permissions (e.g. posts:create, posts:delete).
  • Middleware checks those permissions before a route handler runs.

The result: cleaner code, easier audits, and a security model your team can actually reason about.

security lock code

The Architecture We’ll Build

Here’s the stack for this tutorial:

  • Node.js 22 with Express 5
  • PostgreSQL (the same schema works fine with MySQL)
  • JWT for authentication tokens
  • bcrypt for password hashing

The flow is simple: user logs in, receives a JWT containing their role, and every protected route runs through a middleware that verifies the token and checks whether the role holds the required permission.

Step 1: Design the Database Schema

A robust RBAC schema separates roles from permissions using a many-to-many relationship. Here’s the SQL:

CREATE TABLE users (
  id SERIAL PRIMARY KEY,
  email VARCHAR(255) UNIQUE NOT NULL,
  password_hash VARCHAR(255) NOT NULL,
  role_id INT REFERENCES roles(id)
);

CREATE TABLE roles (
  id SERIAL PRIMARY KEY,
  name VARCHAR(50) UNIQUE NOT NULL
);

CREATE TABLE permissions (
  id SERIAL PRIMARY KEY,
  action VARCHAR(100) UNIQUE NOT NULL
);

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)
);

Seed it with sensible defaults:

Role Permissions
admin posts:create, posts:read, posts:update, posts:delete, users:manage
editor posts:create, posts:read, posts:update
viewer posts:read

Step 2: Set Up the Express Project

Initialize your project and install dependencies:

npm init -y
npm install express pg bcrypt jsonwebtoken dotenv

Create a .env file:

JWT_SECRET=change_me_to_a_long_random_string
DATABASE_URL=postgres://user:pass@localhost:5432/rbac_demo
security lock code

Step 3: Build the Login Endpoint

On successful login, we embed the user’s role and permissions in the JWT so we don’t have to hit the database on every request.

import express from 'express';
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import { pool } from './db.js';

const router = express.Router();

router.post('/login', async (req, res) => {
  const { email, password } = req.body;

  const { rows } = await pool.query(
    `SELECT u.id, u.password_hash, r.name AS role,
            COALESCE(array_agg(p.action) FILTER (WHERE p.action IS NOT NULL), '{}') AS permissions
     FROM users u
     JOIN roles r ON r.id = u.role_id
     LEFT JOIN role_permissions rp ON rp.role_id = r.id
     LEFT JOIN permissions p ON p.id = rp.permission_id
     WHERE u.email = $1
     GROUP BY u.id, r.name`,
    [email]
  );

  const user = rows[0];
  if (!user || !(await bcrypt.compare(password, user.password_hash))) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  const token = jwt.sign(
    { sub: user.id, role: user.role, permissions: user.permissions },
    process.env.JWT_SECRET,
    { expiresIn: '1h' }
  );

  res.json({ token });
});

export default router;

Step 4: Write the Authentication Middleware

This middleware decodes the JWT and attaches the user context to the request.

import jwt from 'jsonwebtoken';

export function authenticate(req, res, next) {
  const header = req.headers.authorization;
  if (!header?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Missing token' });
  }

  try {
    const payload = jwt.verify(header.slice(7), process.env.JWT_SECRET);
    req.user = payload;
    next();
  } catch {
    res.status(401).json({ error: 'Invalid token' });
  }
}

Step 5: Create the RBAC Guard Middleware

This is the heart of the system. It takes a required permission and rejects the request if the user’s role doesn’t include it.

export function requirePermission(permission) {
  return (req, res, next) => {
    const perms = req.user?.permissions || [];
    if (!perms.includes(permission)) {
      return res.status(403).json({ error: 'Forbidden' });
    }
    next();
  };
}

export function requireRole(...allowedRoles) {
  return (req, res, next) => {
    if (!allowedRoles.includes(req.user?.role)) {
      return res.status(403).json({ error: 'Forbidden' });
    }
    next();
  };
}
security lock code

Step 6: Protect Your Routes

Now the fun part: applying the guards. Chain authenticate first, then the permission check.

import express from 'express';
import { authenticate } from './middleware/auth.js';
import { requirePermission, requireRole } from './middleware/rbac.js';

const app = express();
app.use(express.json());

app.get('/posts',
  authenticate,
  requirePermission('posts:read'),
  (req, res) => res.json({ posts: [] })
);

app.post('/posts',
  authenticate,
  requirePermission('posts:create'),
  (req, res) => res.status(201).json({ created: true })
);

app.delete('/posts/:id',
  authenticate,
  requirePermission('posts:delete'),
  (req, res) => res.json({ deleted: req.params.id })
);

app.get('/admin/users',
  authenticate,
  requireRole('admin'),
  (req, res) => res.json({ users: [] })
);

app.listen(3000);

Step 7: Handle Ownership Checks

Pure RBAC has a blind spot: can editor Alice update editor Bob’s post? Both have posts:update. You’ll usually want an ownership layer on top.

export async function requireOwnershipOrRole(role) {
  return async (req, res, next) => {
    if (req.user.role === role) return next();

    const { rows } = await pool.query(
      'SELECT author_id FROM posts WHERE id = $1',
      [req.params.id]
    );
    if (rows[0]?.author_id === req.user.sub) return next();

    res.status(403).json({ error: 'Forbidden' });
  };
}

Use it like: app.put('/posts/:id', authenticate, requirePermission('posts:update'), requireOwnershipOrRole('admin'), handler).

Common Pitfalls to Avoid

  1. Don’t hardcode roles in your handlers. Use permissions, not role names, in most guards. Roles change; permissions are stable.
  2. Beware of stale JWTs. If you demote a user, their existing token still holds the old permissions until it expires. Keep token TTLs short (15 to 60 minutes) and use refresh tokens.
  3. Never trust client-sent role fields. Always resolve roles server-side from the database.
  4. Log denied requests. 403 responses are often the first signal of an attack or a misconfigured integration.
  5. Test your guards. Write integration tests that hit each protected route with each role.
security lock code

Roll Your Own vs. Use a Library

For small to medium apps, the code above is enough. If your requirements grow, consider these options:

Approach Best For Trade-off
Custom (this tutorial) Simple role/permission needs You own maintenance
accesscontrol / CASL Complex rules, attribute checks Learning curve
Oso, Permify, Cerbos Policy-as-code at scale External dependency

Wrapping Up

You now have a fully working role based access control system in Node.js: a clean schema, JWT-based authentication, reusable middleware guards, and patterns to handle ownership. The whole thing fits in under 150 lines of code and scales cleanly as your app grows.

Start with permissions rather than roles in your guards, keep your JWT lifetimes short, and log every denial. Do that, and your authorization layer will hold up under real production pressure.

FAQ

Should I store permissions inside the JWT or fetch them on every request?

Storing them in the JWT is faster but makes revocation slower. For most apps, a short-lived token (15 to 30 minutes) with permissions embedded strikes the right balance. For highly sensitive systems, fetch fresh permissions from Redis on each request.

What’s the difference between RBAC and ABAC?

RBAC grants access based on roles. ABAC (Attribute Based Access Control) evaluates attributes like resource ownership, time of day, or department. Many production systems combine both: RBAC for coarse rules, ABAC for fine-grained ones.

Can I use this pattern with MySQL instead of PostgreSQL?

Yes. Swap the SERIAL types for INT AUTO_INCREMENT and replace the PostgreSQL-specific array_agg with a GROUP_CONCAT query. The middleware layer stays identical.

Does this work with Fastify or NestJS?

The concepts translate directly. NestJS ships with a Guards abstraction that maps one-to-one to the middleware shown here. Fastify uses hooks instead of middleware, but the logic is the same.

How do I handle a user with multiple roles?

Change the role_id column on users into a user_roles join table, then aggregate permissions from all assigned roles when building the JWT payload. The middleware doesn’t need any changes.