A route that exists for admins but carries no role check is reachable by any authenticated session - confirming it requires a three-step differential across unauthenticated, low-privilege, and admin callers.
Broken object-level authorization (BOLA) and broken function-level authorization (BFLA) occupy adjacent rows in the OWASP API Security Top 10 for a reason: they look alike in the route table but confirm differently and fix differently. BOLA asks whether user A can read user B's object - horizontal privilege. BFLA asks whether a regular user can invoke a function reserved for administrators - vertical privilege. The route exists, the handler is live, and the logic inside it is correct. What is missing is the gate that stops the wrong caller from ever reaching it.
BFLA surfaces in three shapes that look unrelated in source but share the same root cause: authorization lives in the wrong place, or nowhere at all.
Pattern 1 is the most obvious: the admin router is mounted without the role middleware attached. Pattern 2 is subtler - the auth middleware confirms a valid session but never asks what role that session holds, so any authenticated caller proceeds. Pattern 3 is HTTP verb confusion: GET /api/users/:id is guarded, DELETE /api/users/:id shares the same resource path and has no guard at all. Each pattern requires a distinct probe because the response it produces differs.
Reachability for BFLA is established when a low-privilege authenticated session receives anything other than 401 or 403 from a function-restricted endpoint. A 200, a 500 that names an internal model, a 422 complaining about a missing field - any of these tells you the gate is absent. None of them is a confirmed exploit on their own.
The confirming artifact is a differential. A low-privilege session and an unauthenticated request are sent to the same endpoint. If the unauthenticated probe returns 401 and the low-privilege probe returns 200 or an action-success code, the gap is between roles, not between authenticated and anonymous callers - which is exactly what BFLA describes. A second differential against an admin session confirms that the function the low-privilege caller reached is truly reserved: both sessions receive 200, but only the admin was supposed to.
# Step 1 - baseline: unauthenticated probe GET /api/admin/users/export HTTP/1.1 HTTP/1.1 401 Unauthorized # Step 2 - low-privilege session (role=viewer) GET /api/admin/users/export HTTP/1.1 Authorization: Bearer <viewer-token> HTTP/1.1 200 OK Content-Type: application/json {"users":[...1 247 records...]} # gate absent for authenticated non-admin # Step 3 - admin session: confirm the function is intentionally restricted GET /api/admin/users/export HTTP/1.1 Authorization: Bearer <admin-token> HTTP/1.1 200 OK # same success - rule is only admin, gate is absent
Probes on destructive functions (bulk delete, impersonation, credential reset) use a nonce resource created by the probe session itself - so no real account or data is modified during confirmation. The confirming evidence is the HTTP response pair; any authentication tokens in that evidence are masked before storage.
Admin endpoints discovered purely by enumeration are one slice of the surface. A larger slice comes from framework-generated route registrations. Rails resources, FastAPI APIRouter, and Django REST Framework ViewSet each generate CRUD routes automatically from a single declaration. If the developer attaches the role middleware to the parent router but the framework generates a child route outside that parent, the generated route inherits nothing.
| Pattern | What the attacker sends | Expected result | BFLA indicator |
|---|---|---|---|
| Missing middleware | Viewer session + admin route | 403 Forbidden | 200 with data |
| Auth without authz | Valid token, wrong role | 403 Forbidden | 200, action completed |
| Verb confusion | DELETE where GET is guarded | 403 Forbidden | 204 No Content |
| Framework-generated route | Non-admin session + auto-CRUD | 403 Forbidden | 200 or 422 from handler |
| Debug / actuator endpoint | Any session + /actuator/env | 404 in production | 200 with internal config |
Framework-generated routes are particularly reliable false-negative sources for static analysis. A SAST tool that reads the route file finds the declared parent with its role check and may not trace through the framework macro that registers five additional endpoints beside it. The attack-surface map that reliably catches these must correlate the SAST-derived route manifest with the endpoints observed at runtime - routes that appear only in the runtime column and carry no detectable role check are BFLA candidates that source review alone missed.
A role check inside the handler body is better than nothing. It is also the worst place to put it, because it relies on every future developer remembering to add it to every new admin handler. One omission in a sprint reopens the gap. The durable fix is a role middleware applied to the router - so a new admin route inherits the check by construction, not by convention.
// Wrong: role check inside each handler const adminRouter = express.Router(); adminRouter.get('/users/export', (req, res) => { if (req.user.role !== 'admin') return res.status(403).json({}); exportUsers(res); // easy to omit on the next route }); // Right: role middleware on the router; every route inherits it const adminRouter = express.Router(); adminRouter.use(requireRole('admin')); adminRouter.get('/users/export', exportHandler); adminRouter.delete('/users/:id', deleteHandler); function requireRole(role) { return (req, res, next) => { if (!req.user || req.user.role !== role) return res.status(403).json({ error: 'Forbidden' }); next(); }; }
HTTP verb coverage is the second gap the middleware must close. A role check applied via router.get() does not cover router.delete() on the same path. Applying the middleware to the router object with router.use() covers every verb registered under it. For frameworks that generate verb handlers from a single resource declaration, confirm that the role middleware wraps the entire resource block, not just the read methods.
Severity follows confirmed capability, not route label. A low-privilege caller reading an admin-only user roster is high - data exposure on a function the role was never supposed to reach. A low-privilege caller invoking a credential reset, account lockout, or impersonation endpoint is critical - the action is irreversible and the potential victim population is every account in the system. In both cases the confirming artifact is the differential response pair and any nonce-resource action proof; the severity is set by what the confirmed function actually does, not by how the path is labeled.