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.rolerouter.use(,app.use(,router.all(next()called unconditionally inside a guard