Skip to content

Broken function-level authorization (roles and permissions)

When is it relevant

A new or changed entry point that should be limited to certain roles or permissions: admin actions, settings, user and role management, billing, key or token management. Also a change to how roles and permissions are assigned or evaluated.

Attack

A lower-privileged user calls the privileged endpoint directly, because the restriction exists only in the UI. Other variants:

  • Sibling routes check a permission but the new one doesn't.
  • The route checks a different permission from the action it performs.
  • A user grants themselves, or others, a role more powerful than their own.

Mitigation

  • Put an explicit permission check on every privileged route, and deny by default.
  • Check the permission for the exact action performed, and keep it consistent across siblings (GET, POST, DELETE).
  • Enforce a delegation ceiling: a user can only grant roles or permissions they already hold.
  • Never trust a role or permission sent by the client.

Examples

Mitigation options

Option When it fits Docs
Router-level middleware A whole area is one privilege level (/admin/*) Express routing
Per-route middleware Mixed privileges inside one router; explicit per endpoint Express routing
Policy/ability layer Rules are data, reused by API and UI CASL

Vulnerable — the check the UI does, the API doesn't

router.get('/admin/users', requireAuth, async (req, res) => {
  res.json(await User.find())          // any logged-in user reaches this
})

Vulnerable — one sibling forgotten

router.get('/courses/:id',    requireAuth, requireTeacher, getCourse)
router.put('/courses/:id',    requireAuth, requireTeacher, updateCourse)
router.delete('/courses/:id', requireAuth,                 deleteCourse)  // gap

Mitigated — router-level middleware

const admin = express.Router()
admin.use(requireAuth, requireRole('admin'))   // applies to every route below

admin.get('/users', async (req, res) => res.json(await User.find()))
admin.delete('/users/:id', async (req, res) => { /* ... */ })

app.use('/admin', admin)

Mounting the middleware on the router means a new route added later is covered by default, which is why a forgotten sibling is less likely than with per-route checks.

Mitigated — per-route middleware

function requireRole(...allowed) {
  return (req, res, next) => {
    const role = req.user?.role                 // from the session, not the body
    if (!allowed.includes(role)) return res.sendStatus(403)
    next()
  }
}

router.delete('/courses/:id', requireAuth, requireRole('teacher', 'admin'), deleteCourse)

Mitigated — policy layer

const ability = defineAbilityFor(req.user)
if (ability.cannot('delete', course)) return res.sendStatus(403)

Docs: CASL

Bypasses to check

  • Check then change the value. The guard and the use read the role differently:
if (req.body.role === 'admin') return res.sendStatus(403)   // reject admins
await User.create({ ...req.body, role: req.body.role.toLowerCase() })

"Admin", " admin" and "ADMİN" (dotted capital I, which lowercases to i̇) all pass the guard, and trimming or normalize('NFKC') afterwards can turn the stored value back into admin. Normalise once, before the check, then use that same normalised value. - Role read from the request (req.body.role, X-Role) or from an unverified JWT claim instead of from the server-side session. - Middleware registered after the route it is meant to protect, or app.use(requireAuth) placed below app.use('/api', routes). - Method not covered: a guard on router.get while router.post, patch or delete on the same path is unguarded; router.all vs one verb. - Path shape differences that skip a mount: trailing slash, %2e%2e, double slashes, or case (/Admin/users) when the guard is mounted on /admin. - Internal or "service" routes that skip auth because they are assumed to be unreachable from the internet. - The check returns but doesn't stop: if (!ok) res.sendStatus(403) without return, so the handler keeps running.

What to grep for

  • requireAuth, requireRole, isAdmin, req.user.role, req.session.role
  • router.use(, app.use(, router.all(
  • next() called unconditionally inside a guard

Resources