Broken Function Level Authorization (BFLA)
ID |
broken_function_level_authorization |
Severity |
high |
Remediation Complexity |
medium |
Remediation Risk |
medium |
Remediation Effort |
medium |
Family |
API5:2023 - Broken Function Level Authorization |
CWE |
CWE-285, CWE-269 |
Resource |
authorization |
Language |
any (model-level) + Java / C# (AST Phase C) + Python / JS / Go / PHP (regex Phase B) |
Description
Reports endpoints whose path or HTTP verb suggests an administrative or privileged function (/admin/, /management/, DELETE /users/{id}, POST /accounts/{id}/promote, …), and that require authentication, but show no model-level evidence of a role-bearing authorization check.
This is OWASP API5:2023 — Broken Function Level Authorization. Per-language source-walking lifts the verdict when no explicit role check (hasRole, [Authorize(Roles=…)], IsInRole, @PreAuthorize("hasRole('ADMIN')"), is_staff, request.user.is_admin, etc.) is found in the handler body.
Rationale
Authentication says the caller is some logged-in user. Function-level authorization is the additional check: is this user allowed to perform this operation? When the check is missing, any authenticated user can elevate their privileges by issuing requests against admin endpoints they discover (or learn from the documentation, or guess from the URL shape).
BFLA differs from BOLA in scope: BOLA leaks one user’s data to another user, while BFLA grants any user the capabilities of an admin. The latter is generally higher-impact — a single missing role check on DELETE /users/{id} lets any authenticated caller delete every user, terminate every session, or rotate every key.
Common failure modes:
-
Admin endpoints behind the same authentication middleware as user endpoints, with no further role gating.
-
@PreAuthorize("isAuthenticated()")(covers only authentication, not role). -
Role checks present on the controller’s user-facing methods but missing from a sibling admin method.
-
New endpoints added to an admin controller without inheriting the class-level role annotation.
Remediation
Add an explicit role-bearing check at the controller, method, or middleware layer. The check must mention roles or permissions, not just authentication.
-
Spring:
@PreAuthorize("hasRole('ADMIN')")or@PreAuthorize("hasAuthority('USER_DELETE')")on the method, or@Secured("ROLE_ADMIN"). -
ASP.NET Core:
[Authorize(Roles = "Admin")]or[Authorize(Policy = "RequireAdmin")]. -
JAX-RS:
@RolesAllowed("admin"). -
Express / NestJS: a
requireRole('admin')middleware or@Roles('admin')decorator + aRolesGuard. -
FastAPI:
Depends(require_role('admin')). -
Flask / DRF:
permission_classes = [IsAdminUser](DRF) or@admin_required(Flask). -
Laravel:
Route::middleware(['auth', 'role:admin'])(with Spatie) orGate::authorize('admin-only', $resource).
Separate admin and user paths under different URL prefixes when feasible — /admin/** for admin routes — and place a global role-gate at the routing layer so an individually missed annotation can’t expose the whole admin surface.
Configuration
What makes an endpoint look privileged is configured by two properties, and each replaces the shipped default rather than extending it:
-
privilegedPathSegments— path segments that make an endpoint administrative. The default covers the common English conventions (admin,administration,internal,management,manage,console,system,sudo,root,superuser,backoffice,ops). -
privilegedHandlerVerbs— verbs that make an operation id, handler method or handler class privileged (delete,destroy,purge,wipe,ban,revoke,disable,grant,suspend,impersonate,approve,reject,takeover).
One further property tunes what counts as a role check:
-
mediatorMethods— per-language names of your own RBAC helpers (requireRole,assert_admin,IsInRole). A call to one inside the handler counts as a role check.
This detector takes no privilegedRoles and no callerIdentities, unlike broken_object_level_authorization. Both vocabularies only decide the object-level question: a role test answers "may this class of caller invoke the operation?" whether the role it names is privileged or not — hasRole("USER") clears a function-level candidate exactly as hasRole("ADMIN") does — and every piece of evidence built from the caller vocabulary (an identity comparison, a caller-bound query, a guard that turns the caller away) speaks to which object is reached, never to which operation may be invoked. Configure them on the object-level detector, where they change the answer.
Suppressions are reported. This detector decides not to report far more often than it reports: a central permission filter authorizes every request, the endpoint’s own declaration carries a role or scope, or the handler walk finds a role check. Each of those removes an endpoint that matched the privileged shape, and a wrong suppression is the expensive direction — it deletes a real finding.
So every suppression is emitted as a finding of this detector at info severity, titled BFLA candidate suppressed: <METHOD> <path>, carrying suppressed_by (which decision removed the candidate: central_function_authz, role_bearing_security_requirement, handler_role_check) and suppression_evidence (what was matched and how it resolved). info keeps them out of the default view and out of the project’s risk level, so auditing the decision cannot bury the confirmed findings. Set reportSuppressions: false in a large repository where the informational volume is unhelpful.
Unless your project uses non-standard admin path conventions, non-English role names, or its own RBAC helpers, you typically do not need to configure this detector. What the application profile contributed, on every finding. When a scan runs with an application profile, the profile can only add to this detector’s allowlist of authorization helpers — the calls your project wrote that count as a check. It can never remove a shipped name, disable this detector or lower a severity. Because a profile is produced by reading your repository and can be wrong, every finding and every suppression says what part the profile played:
-
profile_facts— the fact behind the verdict and thefile:linewhere it is asserted in your code, so you can open that line and disagree with it. All four families report it: the authorization helper that was called, the privileged role that was required, the principal that was recognised and the ownership column that was matched. The one gap is an identity comparison in Ruby, where the caller vocabulary is matched by a pre-compiled expression that cannot say which of its names matched; such a verdict reports no fact. Absence therefore means “no fact was involved” everywhere except that case. -
profile_source— which model produced the facts, which provider served it and when. -
profile_not_evaluated— the questions the profile did not answer, one line each. A scan with no profile says so in a single line. This is what distinguishes "the vocabulary was widened and the verdict did not change" from "the question was never asked".
A suppression a profile fact caused is still emitted at info with the fact named, never dropped: a fact that is wrong about one of your methods withdraws a real finding, and that is the one direction the report has to be able to show you.
References
-
OWASP API Security Top 10 (2023) - API5:2023 - Broken Function Level Authorization.
-
CWE-285 : Improper Authorization.
-
CWE-269 : Improper Privilege Management.
-
OWASP Cheat Sheets Series: Authorization Cheat Sheet.
-
NIST SP 800-53 AC-6 : Least Privilege.