How does BFLA work?
BFLA happens when the API decides who may call a function by what the UI shows, not by a server-side role check. The admin button is hidden from regular users, but the endpoint behind it answers anyone with a valid session.
A regular user's app never shows the "Invite admin" screen. The endpoint still exists:
POST /api/v1/admin/invites HTTP/1.1
Host: api.example
Authorization: Bearer <user_a token, role=member>
Content-Type: application/json
{"invitee": "user_b", "role": "admin"}HTTP/1.1 201 Created
Content-Type: application/json
{"invite_id": "invite_311", "invitee": "user_b", "role": "admin", "status": "pending"}A member-role token just created an admin invite. The correct answer was 403 Forbidden. OWASP's API5:2023 entry describes the same pattern: an invite endpoint meant only for administrators that a user reaches by crafting the request themselves.
BOLA is about which object you can touch. BFLA is about which action you can perform. The two often combine: a missing role check on DELETE /api/v1/admin/projects/{id} is BFLA, and if it also accepts any project ID it is BOLA too.
Where do unprotected functions hide?
Privileged functions are usually discoverable from the client, from naming conventions, or from older versions of the API. Anything the server exposes is callable, whether or not a link to it exists.
- Admin path prefixes.
/admin/,/internal/,/manage/,/support/,/ops/. Change/api/v1/users/meto/api/v1/admin/usersand see what answers. - Method changes.
GET /api/v1/users/user_bis allowed for everyone, and the framework also routesPUTandDELETEon the same path to handlers that never got a role check. - JavaScript bundles. Single-page apps often ship the admin UI's routes and API calls to every user, gated only by a client-side
if (user.isAdmin). - Mobile and partner APIs. A separate API for a partner or an internal tool reuses the same backend but was written assuming only trusted callers.
- Old or undocumented versions.
/v1/kept a check that/v2/forgot, or the reverse. Unlisted endpoints like these are the subject of shadow APIs. - GraphQL mutations. A schema with
deleteUserorsetUserRolemutations available to every authenticated caller, because authorization was added to the queries and not to the mutations.
How do you test for BFLA?
Build a matrix of every function against every role, then call each function with each role's token. Anything a lower role can do that it should not is a finding.
- Get one account per role: anonymous, member, manager, admin, and any support or billing roles. If you only have an admin account, ask for the others; BFLA can't be tested with one role.
- Record the admin's traffic. Use every admin feature once through a proxy. This gives you the real request shapes, including bodies and headers.
- Add discovered endpoints. Pull routes from the OpenAPI spec, from JS bundles, and from GraphQL introspection. Guess common admin paths next to known ones.
- Replay each admin request with lower-role tokens. Change only the
Authorizationheader or cookie. Then try the member request with a different method on the same path. - Classify responses.
401or403is correct.200,201or204is a finding. A400validation error is suspicious: the request passed authorization and failed later. - Check the effect. Confirm from the admin session whether the action happened: the invite exists, the setting changed. Use test objects you created.
- Check role claims. If the role comes from a JWT or a request field, try editing it. That moves the finding toward mass assignment or JWT algorithm confusion.
Write the matrix up as a table in the report; it doubles as the regression test list.
How do you fix BFLA?
Deny by default and require every route to declare the roles it allows. OWASP's guidance for API5 is the same: explicit grants per role, with administrative controllers inheriting a check.
Express, with a route-level guard and a default deny on the admin router:
function requireRole(...roles) {
return (req, res, next) => {
if (!req.user || !roles.includes(req.user.role)) {
return res.status(403).json({ error: "forbidden" });
}
next();
};
}
const admin = express.Router();
admin.use(requireAuth, requireRole("admin")); // every route below inherits this
admin.post("/invites", createInvite);
admin.delete("/projects/:id", deleteProject);
app.use("/api/v1/admin", admin);Django REST Framework, with a global default and explicit overrides:
# settings.py
REST_FRAMEWORK = {
"DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAdminUser"],
}
# views.py: member-facing views opt in explicitly
from rest_framework.permissions import IsAuthenticated
class MyProjectsView(ListAPIView):
permission_classes = [IsAuthenticated]With the global default set to the most restrictive class, a new view that forgets to declare permissions is admin-only, not public.
What holds across stacks:
- Read the role from the server-side session or a verified token. Never from a request field or header the client sets.
- Check every method on a path, not only the one the UI uses.
- Keep admin functions on a separate router, service or host where the check is applied once, at the top.
- Test the role matrix in CI. A test that calls every admin route with a member token and expects
403catches the next regression.
What does not work: hiding buttons, obscure URLs, and relying on the client to never send the request. A WAF won't catch it either, because the request is well formed and authenticated. BFLA is a form of vertical privilege escalation, and it needs a server-side check to close.
[ Sources ]
Written by Parameter · Last reviewed

