September 13, 2026
BOLA Has a Quieter Sibling, and Your Spring Boot API Probably Has It Too
BFLA is what happens when the right user hits the wrong function. It rarely shows up in a tutorial, and it hides in exactly the endpoints…

By Najee Shaheen
4 min read
BFLA is what happens when the right user hits the wrong function. It rarely shows up in a tutorial, and it hides in exactly the endpoints developers stop scrutinizing.
A few weeks ago I wrote about BOLA, the bug where an API checks whether you're logged in, but not whether you should see this specific record. It has a quieter sibling that almost never gets mentioned in the same breath, even though it's just as common in production Spring Boot code: BFLA, Broken Function Level Authorization.
Where BOLA is right function, wrong object — you can see someone else's invoice — BFLA is wrong function, right object entirely. It's not about which record you can reach. It's about which capability you can reach. A regular user calling an admin-only endpoint. A read-only role triggering a write. Both are authorization failures. They just fail on a different axis.
If BOLA is the bug you've probably shipped without knowing its name, BFLA is the bug you've probably shipped without realizing it's a different bug at all. Most developers mentally file it under the same "add an auth check" bucket, and that's exactly why it keeps slipping through.
What BFLA Actually Is
Consider a Spring Boot admin panel with an endpoint for deleting users:
@RestController
@RequestMapping("/api/admin/users")
public class AdminUserController {
private final UserRepository userRepository;
public AdminUserController(UserRepository userRepository) {
this.userRepository = userRepository;
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
userRepository.deleteById(id);
return ResponseEntity.noContent().build();
}
}@RestController
@RequestMapping("/api/admin/users")
public class AdminUserController {
private final UserRepository userRepository;
public AdminUserController(UserRepository userRepository) {
this.userRepository = userRepository;
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
userRepository.deleteById(id);
return ResponseEntity.noContent().build();
}
}This lives under /api/admin/, so the assumption is usually: "the frontend only shows this route to admins, so we're fine." That assumption is the entire bug.
If the endpoint only checks authentication — is there a valid token — and never checks whether the caller's role or permission actually grants access to this function. Any authenticated user who discovers the URL (through the frontend's own JS bundle, an API doc, or simple guessing) can call it directly and delete another user's account.
No object-ownership logic would have caught this. The request isn't asking for someone else's data; it's invoking a privileged action the caller was never supposed to reach.
This is BFLA. The function itself — not any particular record — is the thing that needed protecting, and nothing protected it.
Why It Hides So Well
BFLA survives in codebases longer than BOLA does, for a specific reason: it's invisible from the frontend.
If your React or Angular app only renders the "Delete User" button for admins, the bug never shows up in normal usage or in most manual QA. Every test a regular engineer runs goes through the UI, and the UI never lets a non-admin user click the button. The endpoint looks protected because access to the button is protected. Access to the endpoint isn't.
This is the core trap: frontend route guards and backend authorization are two completely different systems, and it's dangerously easy to build only the first one and assume it covers the second. An attacker doesn't use your UI. They read your JS bundle for API paths, replay a captured request with a different token, or just guess RESTful conventions (if /api/admin/users/{id} exists as a GET, a DELETE at the same path is a reasonable guess).
The Fix: Authorization That Lives With the Function, Not the Button
The fix is to make the function itself refuse to execute for the wrong caller regardless of whether a UI element pointed them at it.
Spring Security gives you a clean, declarative way to do this with method-level security:
@RestController
@RequestMapping("/api/admin/users")
public class AdminUserController {
private final UserRepository userRepository;
public AdminUserController(UserRepository userRepository) {
this.userRepository = userRepository;
}
@PreAuthorize("hasRole('ADMIN')")
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
userRepository.deleteById(id);
return ResponseEntity.noContent().build();
}
}@RestController
@RequestMapping("/api/admin/users")
public class AdminUserController {
private final UserRepository userRepository;
public AdminUserController(UserRepository userRepository) {
this.userRepository = userRepository;
}
@PreAuthorize("hasRole('ADMIN')")
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
userRepository.deleteById(id);
return ResponseEntity.noContent().build();
}
}@PreAuthorize("hasRole('ADMIN')") runs before the method body executes at all. If the authenticated caller doesn't hold the required role, Spring Security throws a 403 before deleteUser ever touches the repository. The check travels with the function, not with whichever screen happens to render a button for it.
Why this beats a manual check inside the method
You could write the same logic by hand:
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id,
@AuthenticationPrincipal UserPrincipal currentUser) {
if (!currentUser.getRoles().contains("ADMIN")) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN);
}
userRepository.deleteById(id);
return ResponseEntity.noContent().build();
}@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id,
@AuthenticationPrincipal UserPrincipal currentUser) {
if (!currentUser.getRoles().contains("ADMIN")) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN);
}
userRepository.deleteById(id);
return ResponseEntity.noContent().build();
}This works, functionally. But it has the same structural weakness the fetch-then-check pattern had in the BOLA article: it depends on every developer remembering to write it, on every privileged endpoint, forever. Miss it once on one admin route out of forty, and that route is wide open.
@PreAuthorize moves the guarantee from "a developer remembered" to "the framework enforces it declaratively, visibly, at the top of the method." A code reviewer scanning a diff sees the annotation immediately. A missing manual check, buried in the method body next to unrelated logic, is much easier to miss in review.
Going Further: Enforce It at the Configuration Level Too
Method-level @PreAuthorize is strong, but it still relies on someone remembering to annotate each new endpoint. For an extra layer, Spring Security lets you define role requirements centrally, by URL pattern, so new endpoints under a sensitive path are covered by default:
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
);
return http.build();
}@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
);
return http.build();
}Now every route under /api/admin/** requires the ADMIN role by default, whether or not anyone remembered to annotate the controller method. A new engineer adding a new admin endpoint six months from now inherits the protection automatically, without needing to know BFLA exists.
This is the same principle from the BOLA article, applied one layer up: move the guarantee from "remembered per-endpoint" to "structural by default." Method-level checks and path-level configuration aren't competing approaches — using both means a missed annotation still gets caught by the URL pattern rule underneath it.
Final Note
Role checks like hasRole('ADMIN') handle the common case: a small number of clearly separated privilege tiers. Real systems often need finer-grained rules: a manager who can approve requests for their own team but not another team's, a support agent who can view but not edit. For those cases, plain role checks aren't enough, and you'll want permission-based or attribute-based checks (Spring Security supports this via custom PermissionEvaluator beans, or SpEL expressions referencing method arguments).
The principle doesn't change.
Authorization still has to live with the function, but the rule itself gets more specific than a role name.
The Takeaway
BOLA asks: does this user own this record? BFLA asks a different question entirely: should this user be able to call this function at all? Both questions get skipped for the same reason: the frontend already hides the button, so it feels handled.
It isn't.
Anything your UI hides is a suggestion.
Anything your backend doesn't enforce is a door left open for anyone who goes looking for it.