Ankush Ananth Bhat
Ankush Ananth Bhat
Backend

Secure Express APIs with Casbin RBAC & JWT

Stop scattering authorization logic across your routes. Learn how to implement scalable Role-Based Access Control (RBAC) in Express.js using Casbin, JWT authentication, and fine-grained permissions.

Secure Express APIs with Casbin RBAC & JWT
Ankush

Ankush Ananth Bhat

10 May 2026

The Authorization Problem Most APIs Eventually Face

Authentication tells you who a user is.

Authorization tells you what they're allowed to do.

Many Express.js applications start with something like:

if (user.role === "admin") {
  // allow access
}

A few months later:

if (user.role === "admin" || user.role === "manager") {
  // allow access
}

Then comes:

  • Different permissions per module
  • Multiple roles per user
  • Temporary access grants
  • Resource ownership rules

Before long, authorization logic is scattered across dozens of routes.

This is where Casbin shines.

Casbin is a powerful and flexible authorization library that allows you to manage access control policies separately from your application logic.

Combined with JWT authentication, it gives you a scalable RBAC system that can grow with your application.


What We'll Build

We'll create a system with three roles:

Role Permissions
Admin Full access
Manager Manage users and reports
User Read-only access

Protected endpoints:

GET    /users
POST   /users
DELETE /users/:id
GET    /reports
POST   /reports

And access control like:

Role GET Users POST Users DELETE Users Reports
Admin
Manager
User Read Only

Installing Dependencies

npm install express jsonwebtoken casbin dotenv

Project structure:

project/
│
├── middleware/
│   ├── auth.js
│   └── authorize.js
│
├── casbin/
│   ├── model.conf
│   └── policy.csv
│
├── routes/
│   └── users.js
│
└── server.js

Step 1: Create the Casbin Model

Create:

casbin/model.conf
[request_definition]
r = sub, obj, act

[policy_definition]
p = sub, obj, act

[role_definition]
g = _, _

[policy_effect]
e = some(where (p.eft == allow))

[matchers]
m = g(r.sub, p.sub) &&
    r.obj == p.obj &&
    r.act == p.act

This defines:

  • Subject → User role
  • Object → Resource
  • Action → HTTP method

Step 2: Define RBAC Policies

Create:

casbin/policy.csv
p, admin, users, GET
p, admin, users, POST
p, admin, users, DELETE

p, admin, reports, GET
p, admin, reports, POST

p, manager, users, GET
p, manager, users, POST

p, manager, reports, GET
p, manager, reports, POST

p, user, users, GET
p, user, reports, GET

This file becomes the single source of truth for permissions.


Step 3: Configure Casbin

Create:

// casbin/enforcer.js

const { newEnforcer } = require("casbin");

let enforcer;

async function initCasbin() {
  enforcer = await newEnforcer("casbin/model.conf", "casbin/policy.csv");
}

function getEnforcer() {
  return enforcer;
}

module.exports = {
  initCasbin,
  getEnforcer,
};

Step 4: JWT Authentication Middleware

Create:

// middleware/auth.js

const jwt = require("jsonwebtoken");

module.exports = (req, res, next) => {
  const authHeader = req.headers.authorization;

  if (!authHeader) {
    return res.status(401).json({
      message: "Unauthorized",
    });
  }

  const token = authHeader.split(" ")[1];

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);

    req.user = decoded;

    next();
  } catch {
    return res.status(401).json({
      message: "Invalid token",
    });
  }
};

JWT payload:

{
  "id": 1,
  "email": "admin@example.com",
  "role": "admin"
}

Step 5: Authorization Middleware

Now let Casbin decide who can access what.

// middleware/authorize.js

const { getEnforcer } = require("../casbin/enforcer");

module.exports = (resource) => {
  return async (req, res, next) => {
    const role = req.user.role;

    const allowed = await getEnforcer().enforce(role, resource, req.method);

    if (!allowed) {
      return res.status(403).json({
        message: "Access denied",
      });
    }

    next();
  };
};

Notice how no permission logic is hardcoded.

Everything comes from policy files.


Protecting Routes

const express = require("express");

const auth = require("../middleware/auth");
const authorize = require("../middleware/authorize");

const router = express.Router();

router.get("/", auth, authorize("users"), (req, res) => {
  res.json({
    message: "Users fetched",
  });
});

router.post("/", auth, authorize("users"), (req, res) => {
  res.json({
    message: "User created",
  });
});

router.delete("/:id", auth, authorize("users"), (req, res) => {
  res.json({
    message: "User deleted",
  });
});

module.exports = router;

The route remains clean and readable.


Generating JWT Tokens

const jwt = require("jsonwebtoken");

function generateToken(user) {
  return jwt.sign(
    {
      id: user.id,
      role: user.role,
      email: user.email,
    },
    process.env.JWT_SECRET,
    {
      expiresIn: "7d",
    },
  );
}

Example token users:

{
  id: 1,
  role: "admin"
}

{
  id: 2,
  role: "manager"
}

{
  id: 3,
  role: "user"
}

Going Beyond Roles: Permission-Based Access

Sometimes roles aren't enough.

You might want:

create:user
update:user
delete:user
view:reports
export:reports

Casbin supports this naturally.

Policy:

p, admin, user:create
p, admin, user:update
p, admin, user:delete

p, manager, user:create
p, manager, user:update

p, user, reports:view

Then check permissions directly:

await enforcer.enforce(role, "user:create");

This gives much finer control than simple roles.


Database-Driven Policies

Hardcoding permissions in CSV files works initially.

For production systems, store policies in:

  • PostgreSQL
  • MySQL
  • MongoDB
  • Redis

Using Casbin adapters:

npm install casbin-pg-adapter

Benefits:

  • Dynamic permission updates
  • Admin permission dashboard
  • Multi-tenant support
  • No redeploy required

Common Production Patterns

1. Role Hierarchy

g, admin, manager
g, manager, user

Admin automatically inherits manager permissions.


2. Ownership Rules

Allow users to edit only their own resources.

if (req.user.id === post.authorId) {
  return next();
}

Combine this with Casbin policies for hybrid RBAC + ABAC.


3. Multi-Tenant SaaS

Tenant A
 ├── Admin
 ├── Manager
 └── User

Tenant B
 ├── Admin
 ├── Manager
 └── User

Casbin supports domain-based RBAC for this use case.


Why Casbin Over Custom Middleware?

Feature Custom RBAC Casbin
Role Management Manual Built-in
Permission Policies Hardcoded Centralized
Role Hierarchies Complex Native
Multi-Tenant Support Difficult Built-in
Database Adapters Manual Available
Maintainability Medium High

For small applications, custom middleware works.

For growing systems, Casbin prevents authorization logic from becoming a maintenance nightmare.


Best Practices

Keep Authentication and Authorization Separate

Authentication:

Who are you?

Authorization:

What can you do?

Never mix the two concerns.


Store Roles in JWT

{
  "role": "manager"
}

This avoids a database lookup on every request.


Keep Policies Centralized

Avoid:

if(role === "admin")

inside route handlers.

Let Casbin handle permission checks.


Audit Permission Changes

Track:

  • Who changed permissions
  • When they changed
  • What changed

Especially important for enterprise systems.


Wrapping Up

Role-Based Access Control is one of those things that starts simple and quickly becomes complex as your application grows.

Using Express.js, JWT, and Casbin gives you:

  • Clean route handlers
  • Centralized authorization policies
  • Role hierarchies
  • Fine-grained permissions
  • Scalability for enterprise applications

Instead of scattering permission checks throughout your codebase, let Casbin become the single source of truth for authorization.

Your future self — and your teammates — will thank you.


Building APIs with Express.js? RBAC is one of the highest-leverage security improvements you can add early. Questions, feedback, or ideas? Drop an email at ankushbhataab@gmail.com.