NodeJSRole-Based Access Control

Role-Based Access Control

Authentication answers who are you? — but once identity is established, you need to answer what are you allowed to do? Role-Based Access Control (RBAC) is the most common model: users are assigned roles (admin, editor, viewer…), and each protected action requires one or more roles. This page covers the hierarchy, implementing role-checking middleware in Express, attribute-based decisions (ownership checks), and the principle of least privilege.

Roles, permissions, and the hierarchy

Text
RBAC model:

  User  → assigned one or more Roles
  Role  → grants a set of Permissions (or is just a label you check)
  Permission → a specific allowed action (e.g. "posts:delete", "users:manage")

Flat RBAC (common for small apps):
  role: 'admin' | 'editor' | 'user'
  → if (user.role === 'admin') allow

Permission-based (scales better):
  user.permissions = ['posts:read', 'posts:write']
  → if (user.permissions.includes('posts:delete')) allow
Start flat, add granularity when you need it
For most apps, a small set of named roles (`admin`, `editor`, `user`) checked directly is simpler and easier to reason about than a full permissions system. Add permission granularity when roles start accumulating too many special cases — e.g. a user who can publish posts but not delete them. The middleware patterns are identical; only what you check changes. Don't over-engineer; start with roles and layer permissions when the need is concrete.
requireRole middleware

JS
// Call AFTER authenticate middleware — req.user must already be set:
export function requireRole(...roles) {
  return (req, res, next) => {
    if (!req.user) return res.status(401).json({ error: 'Not authenticated' })
    if (!roles.includes(req.user.role)) {
      return res.status(403).json({ error: 'Forbidden' })
    }
    next()
  }
}

// Usage:
app.delete('/users/:id',
  authenticate,                     // who are you?
  requireRole('admin'),             // may you do this?
  deleteUserHandler,
)

app.put('/posts/:id',
  authenticate,
  requireRole('admin', 'editor'),   // either role is allowed
  updatePostHandler,
)
Authorization runs AFTER authentication — and 401 vs 403 signals which failed
`requireRole` must come *after* `authenticate` in the middleware chain; it depends on `req.user` being set. If `req.user` is missing (unauthenticated), return **401** — "I don't know who you are." If the user is known but lacks the role, return **403** — "I know who you are, and you may not." Blurring these signals leaks information (403 tells a probing attacker the endpoint exists and they just need a higher-privilege account).
Ownership checks (attribute-based authorization)

JS
// A user can edit THEIR OWN posts; admins can edit any post:
app.put('/posts/:id', authenticate, async (req, res) => {
  const post = await db.posts.findById(req.params.id)
  if (!post) return res.status(404).json({ error: 'Not found' })

  const isOwner = post.authorId === req.user.id
  const isAdmin = req.user.role === 'admin'

  if (!isOwner && !isAdmin) {
    return res.status(403).json({ error: 'Forbidden' })
  }

  const updated = await db.posts.update(req.params.id, req.body)
  res.json(updated)
})
Ownership is an attribute check, not just a role check — combine them
A purely role-based check (`editor` can edit posts) is too broad if editors should only edit *their own* posts. Ownership is an **attribute** of the resource — `post.authorId === req.user.id`. Real authorization often combines both: "you may do this if you own the resource *or* you have the admin role." Build helper functions (e.g. `canEdit(user, post)`) that encapsulate this logic so it's tested and reusable rather than scattered inline.
Never do authorization in the client
Hiding a button is UX — the server must still enforce every permission
Hiding the "Delete" button from non-admins is a useful **user-experience** feature — it reduces confusion. It is **not** security. A determined user can call the endpoint directly with curl. Every authorization check must live on the **server**, evaluated against the verified identity from the token or session. Client-side role checks are decoration; server-side checks are the actual gate.
Roles in JWT vs database

Approach

Pros

Cons

Role in JWT payload

No DB lookup on each request

Stale if role changes until token expires

Role loaded from DB

Always fresh — revoke instantly

Extra query per request (cache it)

Hybrid: role in token + cache bust

Fast + correct

More complexity

Embedding roles in JWTs is fast but can be stale — understand the trade-off
Including `role` in the [JWT payload](/nodejs/jwt) means zero database round-trips on each request — but a role upgrade or revocation only takes effect when the token expires and is refreshed. For most apps the minutes-to-an-hour window is acceptable. For high-security actions (revoking an account, downgrading a compromised admin), either use a short expiry, add a DB lookup on critical endpoints, or maintain a token version number the server can invalidate. Know which operations need always-fresh authorization.
Principle of least privilege
  • Default to the lowest role — users start as user; elevation is explicit.

  • Deny by default — routes are protected unless explicitly opened, never the reverse.

  • Scope API tokens narrowly — if a token only needs read access, don't issue write permission.

  • Audit role assignments — log who was promoted/demoted and who did it.

  • Separate admin actions from regular flows — e.g. admin APIs on a separate router with extra checks.

Next
Apply all of this to your route structure: [Protecting Routes](/nodejs/protecting-routes).