← Blog
October 28, 202610 min readAppSecVulnerability ResearchBug bountyAccess ControlSecurity Engineering

A reachable admin function is not proof: verifying broken function level authorization

Finding an endpoint a normal account was never meant to call — an admin route, a bulk-delete path, a POST where you only saw a GET — and getting a 200 back is where most BFLA reports begin and end. But a reachable function is a question, not an answer. A practitioner's guide to telling object-level access breaks (BOLA) apart from function-level ones, proving a real privilege escalation with a controlled privilege-differential test that never touches a real user, the twin-path gap that a 2026 Bitwarden CVE turned into admin removal, and the deny-by-default checks that close it.

By The Xalgorix Team

The find takes one request. You notice an endpoint a normal account was never meant to call — /api/admin/users, a bulk-delete route, a POST where you only ever saw a GET — you send it, and a 200 OK comes back. The reflex is to write “privilege escalation: admin function exposed” and move on, and that reflex is wrong often enough to get findings bounced. Reaching a privileged function, or getting a 200 from it, is a lead, not a bug. The valuable, harder work is proving that an account which should not be allowed to invoke that function actually causes its privileged effect — because that, and only that, is what a broken function level authorization bug costs.

Object level versus function level

OWASP splits the two most common access-control failures in APIs apart on purpose. Broken Object Level Authorization (API1:2023) is about reaching a specific object you do not own — one user's invoice, another tenant's record — the break we walked through in verifying IDOR and BOLA. Broken Function Level Authorization (API5:2023) is its vertical cousin: can you invoke an operation your role was never granted at all — create an invite as admin, delete another user, change a price, export every account? BOLA asks “whose object is this?” BFLA asks “whose function is this?” They are tested the same way and often chained, but they are not the same finding, and a report that conflates them invites a downgrade. When the function-level check is simply absent, the underlying weakness is CWE-862, Missing Authorization: the server never checks whether this caller may perform this operation at all.

Why a reachable endpoint is not proof

A function that answers is not a function you were allowed to run. At least four everyday situations produce a reachable privileged-looking endpoint that is not an escalation bug:

  • The function is public by design. Plenty of routes that look administrative — a health check, a read-only status page, a self-service action — are meant to answer anyone. Access to something intended to be reachable is not a finding.
  • A 200 that is a no-op or scoped to you. The endpoint responds, but the server quietly filtered the action to your own objects, or returned a generic success without doing anything privileged. Reaching it is not the same as invoking it against something you should not control.
  • The UI hid it; the server still checks. A missing admin link in the menu is a presentation detail. OWASP's testing guide is blunt that the real bug is the reverse — where “developers perform authorization validation at the GUI level only” and leave the underlying function unprotected. So a hidden-but-still-guarded function is not a finding, and a visible one is not automatically a bug. Test the function, not the menu.
  • You never crossed a privilege boundary. Calling the function as the admin, or replaying the admin's own token, proves nothing. The bug is precisely that a lower-privileged caller succeeds — which you can only show from a lower-privileged context (below).
Privileged function returns 200 to a non-privileged caller
Public by design
Meant to answer anyone — not a finding
No-op or own-scope
Server filtered to your data or did nothing privileged
Privileged effect for a caller without the role
The function ran and persisted — the bug
Only the right-hand branch is the vertical escalation you are trying to prove.

The shapes BFLA takes

Vertical escalation is the headline case — a regular user invoking an admin-only function — but the finding hides in a few recurring patterns, and the one that catches testers out most is the twin-path gap: a check that exists on one route is missing on its sibling.

Vertical
regular user → admin functionguessed admin routeforced browsing
Twin-path gap
GET guarded, DELETE notsingle guarded, bulk notaliased route
Parameter / role
groupID swapprofile=SysAdminrole field in body
A guard on one operation rarely covers all of its siblings — that is where to look.

OWASP's privilege-escalation tests name the parameter cases directly: a POST that carries a groupID an outside user can change, and a hidden field where the guide asks “What if the tester modifies the value of the variable profile to SysAdmin?” The twin-path gap is subtler and more common in modern APIs, because teams add a bulk or batch variant of an operation long after the original and forget it needs the same guard.

A real one: CVE-2026-57520

In June 2026, VulnCheck published CVE-2026-57520 in Bitwarden Server, and it is a near-perfect specimen of the twin-path gap. The single-user removal path enforced a role-hierarchy guard: an authenticated Custom user holding the ManageUsers permission could not remove an Admin. The bulk user-remove endpoint did not. Per the CVE record, that Custom user could “supply Admin organization-user IDs in a bulk DELETE request to bypass the guard enforced on the single-user removal path,” removing one or more Admin accounts from the organization. It is CWE-862, scored CVSS 7.1 (high) on both v3.1 and v4.0, and fixed in 2026.5.0.

Two things make it instructive. First, the lesson for verification: when you find a guard on one operation, its siblings are where the bug usually lives — the method you did not try, the bulk variant, the alias. A scanner that confirmed the single-user path was protected would have called the feature secure. Second, the honesty the brand insists on: at disclosure the CVE carried a proof-of-concept classification, not confirmed in-the-wild exploitation. “Someone could” and “someone did” are different claims, and a report should say which one it is making.

Verifying it safely: the privilege differential

The clean proof mirrors the two-account differential used to confirm an IDOR, but across privilege levels rather than across peers. OWASP's guide describes the setup precisely — register a user for each role, keep two sessions open, and for each request “change the session identifier from the original to another role's session identifier” and compare. The application is vulnerable if the weaker-privileged session can perform the higher-privileged operation. Concretely, on an environment you are authorised to test:

  • Provision two accounts — one low-privilege, one higher-privilege — and, where the function acts on objects, test objects you own on both sides.
  • As the high-privilege account, capture the exact privileged request: method, path, body, and headers.
  • Replay it authenticated as the low-privilege account — the same request, that account's own session and tokens.
  • Confirm the privileged effect happened, not just that a 200 came back: verify it from the privileged side, or use a reversible, benign operation on an object you own, and check that it persisted.
# 0) Capture the exact privileged request's shape first (method, path, body)
#    by observing the admin perform it in a proxy — do NOT run the destructive
#    call as admin against the target you will test, or step 3 proves nothing.

# 1) As the HIGH-priv (admin) account, confirm the throwaway target EXISTS.
#    This is the user you own that the low-priv call will try to remove.
curl -s -o /dev/null -w "%{http_code}\n" \
  https://target.example/api/org/ORG/users/<throwaway-id> \
  -H "Authorization: Bearer <ADMIN_TOKEN>"        # expect 200 — target present

# 2) As the LOW-priv account, send the privileged bulk-remove for that target:
curl -i -X DELETE https://target.example/api/org/ORG/users/remove-bulk \
  -H "Authorization: Bearer <LOW_PRIV_TOKEN>" \
  -H "Content-Type: application/json" \
  -d '{"ids": ["<throwaway-id>"]}'

# 3) Back as admin, re-check the target. If it is now GONE, the low-priv call
#    caused a removal its role was never granted — that deletion is the proof.
curl -s -o /dev/null -w "%{http_code}\n" \
  https://target.example/api/org/ORG/users/<throwaway-id> \
  -H "Authorization: Bearer <ADMIN_TOKEN>"        # 404 -> low-priv removed it

# The proof is not the 200 in step 2. It is the target disappearing between
# step 1 and step 3 because of the low-priv call. A 200 with the target still
# present in step 3 is a no-op, not a bug.
Function reachable
200 to a non-admin
Capture as admin
exact privileged call
Replay as low-priv
that account's session
Effect persisted
verified from admin side
Proof
role never granted it
Each step adds a fact the previous one could not supply; the last is the proof.

Keep the blast radius to objects and accounts you own: reversible, benign operations, never a real user's data, and never a destructive admin action on shared state. Prefer a privileged read, or a create-then-delete on a throwaway object, over anything that touches another tenant. And test the twins — every method on the resource, the bulk and batch variants, and any aliased route — because the Bitwarden case lived in exactly the sibling a single-path test skips.

This is also where automated testing hits its ceiling, honestly including ours. A scanner can enumerate the endpoint and read the status code; it cannot, on its own, tell a decoy 200 from a real privileged effect, because that needs a model of which role should be able to do what, two authenticated contexts to compare, and a check that the effect persisted. That gap — detection versus a re-exploited, verified effect — is exactly what exploit-verification exists to close, and the discipline Xalgorix is built on: treat a reachable function as a candidate, then try to make its privileged effect actually happen before calling it a finding. It is also why fully autonomous authorization testing stays hard, ours included: the role model is business logic, and no tool infers for free which caller should be allowed to do what.

The traps

  • A 200 is not an effect. Confirm the privileged action persisted, from the privileged side — not from the response code of the call that supposedly performed it.
  • Own-scope success is not escalation. If the server silently filtered the operation to your own data, you proved you can act on your own data.
  • UI-only reasoning cuts both ways. A hidden function may still be guarded; a visible one may not be. The menu is not the control.
  • Forgotten twins. The method, alias, or bulk variant you did not try is the most likely home of the missing check.
  • Horizontal versus vertical. Reaching a peer's object is BOLA; invoking a higher role's function is BFLA. Label the finding for what it is.

The fix that holds

The defense is authorization that denies by default and is enforced centrally, at a trusted layer, on every operation. OWASP's Authorization Cheat Sheet is direct: “an application should be configured to deny access by default,” “permission should be validated correctly on every request” because “an attacker only needs to find one way in,” and the checks “must be performed server-side” — never client-side. The structural fix for the twin-path gap is to make the check a property of the operation, not of each route, so a new variant cannot ship without it.

// Deny by default: one role check, applied to every variant of the operation.
function requireRole(min) {
  return (req, res, next) => {
    if (!req.user || !roleAtLeast(req.user.role, min)) {
      return res.status(403).end();   // default is NO
    }
    next();
  };
}

// The single-user path AND the bulk path share the same guard.
// A bulk endpoint that skips this is the CVE-2026-57520 shape.
// Register the literal bulk route BEFORE the parameterized one, or
// "/org/:id/users/remove-bulk" matches :userId = "remove-bulk" and the
// single-user handler runs instead of removeUsersBulk.
router.delete("/org/:id/users/remove-bulk", requireRole("admin"), removeUsersBulk);
router.delete("/org/:id/users/:userId", requireRole("admin"), removeUser);

// And re-check at the function, not just the route: for each target id,
// confirm the ACTOR outranks the TARGET before removing it.
function removeUsersBulk(req, res) {
  for (const id of req.body.ids) {
    const target = getOrgUser(req.params.id, id);
    if (!roleOutranks(req.user, target)) return res.status(403).end();
  }
  // ... proceed only once every target passed the hierarchy check
}

Two rules keep that correct rather than merely plausible. Enforce least privilege both vertically and horizontally, so a “Custom” role cannot act on a role above it; and validate on every request — every method, every bulk and alias variant — because, as the cheat sheet puts it, one missed check is the one that gets used. The role model is yours to define; the enforcement is what must never be optional.

The one-line version: a reachable admin function is a lead, not a loss. The bug exists only when an account without the role can invoke the function and its privileged effect lands — so prove it by replaying the captured privileged call from a lower-privileged account you own, against objects you own, and confirming the effect persisted. That is the distance between “the endpoint answered” and proof.

Ready to see it prove a bug?

Start scanning on Cloud →