API Security · intermediate · ~10 min

Broken Function Level Authorization (BFLA)

**What you will learn** - Define BFLA and explain how it differs from BOLA at a mechanical level (function/action vs. object). - Recognise the three most common BFLA patterns: hidden admin endpoints, HTTP method swaps, and privilege-unlocking parameters. - Test safely for missing function-level authorization in an authorized lab, and record evidence like a professional. - Design a **deny-by-default**, server-side role check that gates every privileged function. - Verify a fix works by proving the endpoint *rejects* a low-privileged caller and *accepts* an authorized one. - Choose the right logs and detections to catch BFLA attempts in production.

Overview

Security objective. The asset you are protecting is a set of privileged functions in an API — actions such as deleting a user, promoting an account to admin, reading a full user list, or changing another account's role. The threat is privilege escalation: a low-privileged authenticated user (or an attacker who has stolen a normal user's token) invokes an admin-only function the API forgot to gate. By the end of this lesson you will be able to detect this gap in an authorized lab and prevent it with a server-side role check.

What BFLA is. Broken Function Level Authorization (BFLA) is a missing or incorrect role/function check. The API correctly authenticates the caller — it knows who they are — but never authorizes the action — it never asks are you allowed to run this function? So a normal user can call DELETE /api/v1/users/5002 or GET /api/v1/admin/users and the server happily obeys.

Where it lives. BFLA is number five on the OWASP API Security Top 10 (API5:2023, Broken Function Level Authorization). It shows up wherever an API exposes admin, management, or internal endpoints alongside ordinary user endpoints and relies on the client (a hidden button, a role-gated menu) instead of the server to keep them apart.

How it connects to your prereq. You just studied BOLA (Broken Object Level Authorization) — accessing another user's object by changing an ID. BFLA is its sibling. BOLA asks "can I reach a record that is not mine?" BFLA asks "can I run an action above my role?" Same root disease (authorization enforced in the wrong place, or not at all), different symptom. Together they are the two most-reported classes of API vulnerability, so understanding both gives you most of the API authorization landscape.

Why it matters

In real, authorized security work, BFLA is one of the highest-value findings you can report because it maps directly to impact a business understands: a regular customer account performing administrator actions.

  • It is privilege escalation. A single unchecked POST /api/v1/admin/promote can turn any signed-up user into an administrator. From there an attacker can read every record, disable other accounts, or exfiltrate the whole dataset.
  • It is common and cheap to exploit. No memory corruption, no cryptography — just an HTTP request with a normal user's token pointed at an admin path or verb. That low barrier is exactly why it dominates real bug-bounty and pentest reports.
  • It hides behind "the UI doesn't show that." Teams routinely believe an action is safe because non-admins can't see the button. The API still accepts the call. As a professional you demonstrate that gap and quantify it.
  • It is fixable with a clear, testable control. Unlike vague "harden the system" advice, BFLA has a crisp remediation (server-side per-function role check, deny by default) and a crisp verification (the endpoint now returns 403 to a normal user and 200 to an admin). That makes your report actionable and your retest unambiguous.

Remember two professional caveats you must state in every engagement: you only test systems you own or are explicitly authorized to test, and passing an automated scanner never proves authorization is correct — role logic almost always needs a human to reason about who should be allowed to do what.

Core concepts

1. Authentication vs. authorization (the root distinction)

Definition. Authentication answers "who are you?" Authorization answers "are you allowed to do this?" BFLA is a pure authorization failure sitting on top of working authentication.

How it works. A request arrives with a valid token. Middleware verifies the token signature and loads the user — authentication succeeds. The handler then performs the privileged action without asking whether that user's role permits it. The check that should say if not is_admin(user): deny is simply absent.

When it applies / when not. BFLA applies to function/role gates. If the missing check is instead "does this object belong to this user?", that is BOLA, not BFLA. If the token itself is invalid or forged, that is an authentication problem, not BFLA.

Pitfall. Verifying a token is not authorization. Decoding a JWT to read role: user tells you nothing about whether the endpoint enforced it. A valid token for a normal user should still be rejected by an admin endpoint.

2. BFLA vs. BOLA

Question you are asking Flaw Example
Can I reach another user's object/record? BOLA GET /api/v1/users/5002/invoices as user 4001
Can I perform an action or role above my privilege? BFLA DELETE /api/v1/users/5002 as a standard user
Can I read an admin-only collection? BFLA GET /api/v1/admin/users returns data to a normal user

Plain explanation. BOLA is a horizontal or object problem — "that's not your data." BFLA is a vertical or function problem — "that's not your job." A single endpoint can even have both bugs at once.

Pitfall. People report every authorization bug as "BOLA." Ask the disambiguating question: is the failure about which object or about which function/role? The answer names the flaw.

3. The three BFLA patterns

(a) Hidden admin endpoints. Paths like /admin/..., /internal/..., /manage/... that are absent from the normal-user UI but still respond to a normal-user token.

(b) HTTP method swaps. The UI only issues GET /api/v1/users/5002, but the API also implements PUT and DELETE on the same path and forgets to gate the dangerous verbs. Same URL, different method, different authorization requirement.

(c) Privilege-unlocking parameters. A field the client never sends — ?role=admin, "is_admin": true, "account_type": "staff" — that a low-privileged user can add to unlock elevated behaviour. (This overlaps with mass assignment, your next lesson.)

When not. Not every 200 from an /admin path is BFLA — an admin token should succeed. BFLA is specifically a lower-privileged identity succeeding at a higher-privileged function.

4. Deny by default (the fixing principle)

Definition. The server starts from "forbidden" and grants access only when an explicit rule permits it. New endpoints are locked until someone deliberately opens them.

How it works. Centralised authorization middleware maps each route+method to a required role or permission. If a route has no mapping, the request is denied — so forgetting to add a rule fails closed, not open.

Pitfall (allow by default). The opposite pattern — "everything is allowed unless a rule blocks it" — means every newly added admin endpoint is exposed until someone remembers to lock it. That is the exact condition BFLA thrives in.

Threat model

                 TRUST BOUNDARY (network edge / API gateway)
                 |
  Entry points:  |   [ Public internet ]
   Attacker  ----+--> POST /login            (get a NORMAL user token)
   with a        |
   valid normal  +--> GET  /api/v1/admin/users   <-- privileged FUNCTION
   user token    +--> DELETE /api/v1/users/{id}   <-- privileged FUNCTION
                 |    PUT  /api/v1/users/{id}/role
                 |
                 v
           +----------------------------------------+
           |  API service                           |
           |  [authN] verify token  -> OK (user)    |  <- who: works
           |  [authZ] role check    -> MISSING (!!) |  <- what: BFLA GAP
           |  Handler executes admin action anyway  |
           +----------------------------------------+
                 |
                 v
     Protected assets: user table, roles, admin operations

Assets: the user table, role assignments, and every admin/management operation. Trust boundary: the API gateway/edge — beyond it, treat callers as untrusted regardless of their token. Entry points: privileged routes and their method/parameter variants. The gap is the missing [authZ] step.

Knowledge check

  1. What asset is protected here? (The privileged functions and the user/role data they touch.)
  2. Where is the trust boundary, and what insecure assumption crosses it? (At the API edge; the insecure assumption is "a valid token means an allowed action" — i.e., authentication mistaken for authorization.)
  3. Which log line would first reveal an attempt? (A request from a role=user principal to an /admin route or a dangerous verb — especially one that returned 200 instead of 403.)
  4. Why must this only be tested in an authorized lab? (Calling admin functions on a system you don't own or aren't authorized to test is unlawful unauthorized access, regardless of intent.)

Syntax notes

The core of BFLA testing is issuing the same privileged request under different identities and comparing responses. Two lab-safe primitives:

1. Send a request as a chosen identity (curl):

# TOKEN_USER  = a NORMAL user's bearer token (low privilege)
# TOKEN_ADMIN = a legitimate ADMIN's bearer token
# All against a LOCAL lab you control (e.g. http://localhost:8080)

curl -i -X DELETE \
  -H "Authorization: Bearer ${TOKEN_USER}" \
  http://localhost:8080/api/v1/users/5002
#  -i    : show status line + headers (you compare 200 vs 403)
#  -X    : choose the HTTP method (swap GET -> PUT/DELETE to test verbs)

2. The comparison that defines the test:

Same request, two identities:
  as ADMIN  -> expect 200/204  (the function is real and works)
  as USER   -> expect 403      (deny)  ... if you get 200, that's BFLA

The key structure of a fix is a per-function guard that runs before the handler body:

require_role("admin")  ->  if caller.role != "admin": return 403; else continue

Annotate mentally: the guard runs on the server, on every privileged route+method, and defaults to deny.

Lesson

Authorization bugs come in two flavors. It helps to keep them straight:

  • BOLA is about objects — can you read or change another user's record?
  • BFLA is about functions and actions — can you call an endpoint meant for a higher role?

BFLA (Broken Function Level Authorization) happens when a lower-privileged user invokes an endpoint or action reserved for an admin.

The pattern

GET    /api/v1/users/me        <- any user
GET    /api/v1/admin/users     <- admin-only ... but returns data to a normal user
DELETE /api/v1/users/5002      <- admin action a normal user can call

The same flaw appears when an admin action is triggered through an HTTP method or parameter the UI hides from non-admins.

The root cause is consistent: the endpoint authenticates the caller (confirms who they are) but never authorizes the action (checks whether their role is allowed to run it).

How to test

  • Find privileged endpoints. Look in docs and JavaScript, or guess common paths like /admin and /internal, plus management verbs.
  • Call them as a low-privileged user. If the call succeeds, you have found BFLA.
  • Swap HTTP methods. The UI may only expose GET, but the API might still accept PUT or DELETE.
  • Try role-specific parameters that unlock privileged behavior.

BOLA vs BFLA: a quick test

  • Can I access another user's record? -> BOLA.
  • Can I perform an action or role above my privilege? -> BFLA.

The fix

Enforce function-level authorization on the server:

  • Every privileged endpoint and method checks the caller's role.
  • Deny by default — allow only what is explicitly permitted.
  • Do not rely on the UI hiding admin features. Hidden buttons are not access control; the API itself must enforce the rule.

Code examples

The example below uses Python with Flask-style pseudo-routes because it reads clearly; the pattern is identical in Express, Spring, Rails, etc. It follows INSECURE -> SECURE -> VERIFY.

(1) WARNING: intentionally vulnerable — use only in a local, isolated, authorized lab. Do not deploy.

# vuln_api.py  -- LAB ONLY. Do NOT deploy.
from flask import Flask, request, jsonify

app = Flask(__name__)

# Pretend token store: token -> (user_id, role)
TOKENS = {
    "tok-user":  (4001, "user"),
    "tok-admin": (1, "admin"),
}
USERS = {5002: {"id": 5002, "email": "target@example.test"}}

def current_user():
    tok = request.headers.get("Authorization", "").removeprefix("Bearer ")
    return TOKENS.get(tok)  # (user_id, role) or None

@app.delete("/api/v1/users/<int:uid>")
def delete_user(uid):
    user = current_user()
    if user is None:
        return jsonify(error="unauthenticated"), 401
    # BUG: authentication done, but NO role check (authorization) at all.
    USERS.pop(uid, None)
    return jsonify(deleted=uid), 200

A normal user's token tok-user can delete account 5002 — classic BFLA. The handler confirmed who the caller is, never whether their role may delete users.

(2) SECURE fix — deny by default, server-side per-function role check

# secure_api.py
from functools import wraps
from flask import Flask, request, jsonify

app = Flask(__name__)

TOKENS = {"tok-user": (4001, "user"), "tok-admin": (1, "admin")}
USERS = {5002: {"id": 5002, "email": "target@example.test"}}

def current_user():
    tok = request.headers.get("Authorization", "").removeprefix("Bearer ")
    return TOKENS.get(tok)

def require_role(required):
    """Deny-by-default guard: runs BEFORE the handler body."""
    def decorator(fn):
        @wraps(fn)
        def wrapper(*args, **kwargs):
            user = current_user()
            if user is None:
                return jsonify(error="unauthenticated"), 401
            _uid, role = user
            if role != required:
                # log the denied privileged attempt (see safety notes)
                app.logger.warning(
                    "authz_deny route=%s method=%s uid=%s role=%s need=%s",
                    request.path, request.method, _uid, role, required)
                return jsonify(error="forbidden"), 403
            return fn(*args, **kwargs)
        return wrapper
    return decorator

@app.delete("/api/v1/users/<int:uid>")
@require_role("admin")          # <-- authorization, enforced server-side
def delete_user(uid):
    USERS.pop(uid, None)
    return jsonify(deleted=uid), 200

Now the role check runs first and defaults to deny. Ideally this guard lives in centralised middleware keyed by route+method, so a new admin route is locked until someone maps it — but the decorator shows the principle concretely.

(3) VERIFY — prove it REJECTS bad input and ACCEPTS good input

# Against the SECURE app on localhost. Expect: user denied, admin allowed.

# Bad input: normal user attempts an admin function -> must be 403
curl -s -o /dev/null -w "user  -> %{http_code}\n" \
  -X DELETE -H "Authorization: Bearer tok-user" \
  http://localhost:8080/api/v1/users/5002

# Good input: legitimate admin performs the function -> must be 200
curl -s -o /dev/null -w "admin -> %{http_code}\n" \
  -X DELETE -H "Authorization: Bearer tok-admin" \
  http://localhost:8080/api/v1/users/5002

# Missing token -> must be 401
curl -s -o /dev/null -w "anon  -> %{http_code}\n" \
  -X DELETE http://localhost:8080/api/v1/users/5002

Expected output:

user  -> 403
admin -> 200
anon  -> 401

The fix is proven only when all three lines hold: the low-privileged identity is rejected (403), the authorized identity succeeds (200), and no identity is treated as anonymous-allowed (401). Run the same three checks against the vulnerable app first and you will see user -> 200, which is the failing state you are remediating.

Line by line

Walking through the secure handler for the request DELETE /api/v1/users/5002 sent with Authorization: Bearer tok-user:

  1. Flask routes the request to delete_user, but the @require_role("admin") decorator wraps it, so wrapper runs first. This ordering is the whole point: authorization happens before any side effect.
  2. current_user() reads the Authorization header, strips Bearer , and looks up tok-user -> (4001, "user"). Authentication succeeds; user is not None.
  3. The guard unpacks _uid = 4001, role = "user".
  4. role != required -> "user" != "admin" -> True. The guard takes the deny branch.
  5. It logs one structured line (route, method, uid, role, need) — a security decision recorded for detection — then returns 403 forbidden. The handler body never runs, so USERS.pop is never reached and account 5002 is untouched.

Now the same request with Bearer tok-admin:

Step Value Effect
lookup token tok-admin -> (1, "admin") authenticated
role compare "admin" != "admin" -> False passes the guard
call fn delete_user(5002) runs USERS.pop(5002)
response 200 {deleted: 5002} authorized action completes

And with no token: current_user() returns None, the guard returns 401 before touching roles. The three branches — 401 (no identity), 403 (wrong role), 200 (right role) — are exactly what your VERIFY step asserts.

Contrast this with the vulnerable handler: there, step 1 has no decorator, so execution jumps straight into the body; the only check is user is None (authentication). Step 4's role comparison simply does not exist, so "user" sails through and USERS.pop(5002) deletes the account. The single missing line is the entire vulnerability.

Common mistakes

Mistake 1 — Treating a valid token as permission.

  • Wrong: "The request had a valid JWT, so it's authorized."
  • Why wrong: A valid token proves identity (authentication), not entitlement. A normal user's token is perfectly valid and must still be refused by admin functions.
  • Corrected: After authenticating, run an explicit role/permission check per function.
  • Recognise/prevent: If your handler reads the token but never compares a role or permission before acting, you have this bug.

Mistake 2 — Enforcing authorization only in the UI.

  • Wrong: Hide the "Delete user" button from non-admins and call it done.
  • Why wrong: The API still accepts the call. Anyone can craft the request with curl/Postman; the client is fully attacker-controlled.
  • Corrected: Enforce on the server, in the API, for every privileged route and method.
  • Recognise/prevent: Ask "if I bypass the UI entirely, what stops me?" If the answer is "nothing," the control is missing.

Mistake 3 — Gating only the verb the UI uses.

  • Wrong: Add a role check to GET /users/{id} but leave PUT/DELETE on the same path unguarded.
  • Why wrong: BFLA loves method swaps; the dangerous verbs are often the unchecked ones.
  • Corrected: Authorize per route and method; deny methods you don't intend to support.
  • Recognise/prevent: Enumerate every method your framework auto-registers for a route and confirm each has an explicit rule.

Mistake 4 — Allow-by-default routing.

  • Wrong: New endpoints are open unless someone remembers to protect them.
  • Why wrong: Every future admin endpoint ships exposed. BFLA becomes the default.
  • Corrected: Centralised deny-by-default: unmapped routes are forbidden.
  • Recognise/prevent: Add a test that a brand-new, unmapped admin route returns 403 to everyone.

Mistake 5 — Trusting a client-supplied role.

  • Wrong: Read role from the request body or a ?role=admin query parameter.
  • Why wrong: The client controls those; it will simply claim to be admin.
  • Corrected: Derive role from the server-side session/verified token, never from request input.
  • Recognise/prevent: Grep handlers for reading role/is_admin from request.*; that is a red flag.

Debugging tips

When a BFLA test or a fix behaves unexpectedly, work through these:

  • You got 200 as a normal user but expected 403. The role check is missing or runs after the action. Confirm the guard executes before the handler body (decorator/middleware ordering) and that it actually compares a role, not just user is not None.
  • You got 403 as an admin (fix too strict). Check the required-role string matches the admin role exactly ("admin" vs "administrator"), and that the admin token maps to that role in your store. Log the observed role vs need to see the mismatch.
  • 401 when you expected 403. Your token isn't being parsed — often a missing Bearer prefix or a header-name typo. Print the raw Authorization header the server received.
  • The /admin path 404s. It may be a different base path or version (/api/v2, /internal). Confirm the route exists (test it with the admin token first — an admin 200 proves the function is real before you test the user identity).
  • Method swap returns 405 Method Not Allowed. Good — that method isn't registered, so it can't be BFLA. But confirm it's a true 405 from the framework, not a proxy stripping the verb.

Questions to ask when it fails:

  1. Does the privileged function work at all as admin? (Establish the baseline before testing the low-privileged identity.)
  2. Is the role derived from the verified token/session, or from request input?
  3. Is the check per route and method?
  4. Does an unmapped route default to deny?
  5. Did I compare the same request across identities, changing only the token?

Memory safety

Security & safety — detection, logging, and authorization for BFLA.

What to log on every privileged request (allowed and denied):

  • Timestamp (UTC, ISO-8601).
  • Source: client IP and the authenticated principal (user id) — never the raw token.
  • Resource: route path + HTTP method (e.g. DELETE /api/v1/users/5002).
  • Result: HTTP status and the security decision (authz_allow / authz_deny, with the required role).
  • Correlation/request id so one user's burst of attempts can be stitched together.

What to NEVER log: passwords, bearer tokens or session cookies, JWT contents, API keys, private keys, full payment card numbers (PANs), or PII you don't need. Log the user id, not the credential. If you must reference a token for debugging, store only a short irreversible hash.

Events that signal BFLA abuse:

  • A principal whose role is user generating authz_deny on /admin, /internal, or dangerous verbs — especially many in a short window (enumeration).
  • Method swaps against a path the user normally only GETs.
  • Any privileged route that returns 200 to a non-privileged principal — this should be impossible and warrants an immediate alert, since it means the control failed, not just that it fired.
  • Requests carrying client-supplied role/is_admin fields.

How false positives arise: legitimate role changes (a user genuinely promoted to admin mid-session with a stale token), internal health-check or admin tooling using service accounts, and shared IPs (offices, NAT, CI runners) that make one source look like many users. Tune by alerting on the decision + principal role, not raw IP volume, and by whitelisting known service accounts explicitly.

Authorization & ethics (mandatory). Only test systems you own or are explicitly authorized to test. Run BFLA labs on localhost, containers, intentionally-vulnerable apps, or CTF targets — never third-party production. Passing a scanner does not prove authorization is correct, and nothing is ever "completely secure"; you are reducing risk and proving specific controls hold.

Authorization checklist before any lab test:

  • Written scope/authorization covers this host and these endpoints.
  • Target is a lab you control (localhost/container/vuln-VM/CTF).
  • Test accounts are yours; no real user data is touched.
  • Actions are reversible or run against disposable data.
  • You have a cleanup/reset plan (below).

Lab cleanup / reset: stop and remove the lab container or rebuild from the seed/snapshot so deleted or promoted accounts are restored; clear any test tokens; and delete local evidence files (request/response captures) that contain lab data once your notes are written. Re-seed the user table before the next run so DELETE /users/5002 starts from a known state.

Real-world uses

Concrete authorized use case. A SaaS company hires you to pentest its account-management API before launch. In scope: a staging clone at https://staging.internal.example with seeded test accounts. You log in as an ordinary user, enumerate documented and JavaScript-referenced routes, and find POST /api/v1/admin/users/{id}/role. Sent with your normal user token it returns 200 and promotes your own account to admin — a critical BFLA finding. You report it with the finding template (title, severity, affected component, preconditions, safe reproduction, evidence, impact, likelihood, remediation, retest), the developers add a deny-by-default role guard, and you retest: the same request now returns 403 for a user and 200 only for a real admin. That retest is what closes the finding.

Professional best-practice habits this builds:

Habit Beginner application Advanced application
Validate identity and entitlement Add an explicit role check to each admin route Centralised policy engine (RBAC/ABAC) evaluated in middleware for every route+method
Least privilege Give test/service accounts only the roles they need Scoped tokens/permissions per operation; no ambient admin
Secure defaults Deny unless a rule allows Framework fails closed; unmapped routes 403 by contract, enforced by CI
Logging & detection Log allow/deny decisions with user id Alert on non-privileged principals hitting privileged routes; dashboards for authz_deny spikes
Error handling Return a plain 403 without leaking internals Uniform 401/403 semantics; no oracle that distinguishes "exists" from "forbidden"

Beginner vs advanced testing. A beginner compares one privileged request across two identities and reports the diff. An advanced tester systematically enumerates every route+method, builds a matrix of role x endpoint expected results, automates the comparison in an authorized harness, and checks method swaps and privilege-unlocking parameters — while still reasoning by hand about the business rules, because authorization correctness is a human judgement no scanner fully captures.

Practice tasks

All tasks are lab-only: run them against a local, isolated, authorized target (localhost, a container, or an intentionally-vulnerable app) with test accounts you own. Each ends by remediating and verifying — the defensive point is the goal.

Beginner 1 — Name the flaw.

  • Objective: Correctly classify five authorization scenarios as BOLA, BFLA, or neither.
  • Requirements: For each (e.g. GET /users/9/invoices as user 3; DELETE /users/9 as a standard user; GET /admin/users returning data to a normal user), write the label and the disambiguating reason (object vs. function/role).
  • Constraints: No live system needed; reason from the request shape.
  • Hints: Ask "which object?" vs "which function/role?".
  • Concepts: BFLA vs BOLA, authentication vs authorization.

Beginner 2 — Baseline then diff.

  • Objective: Prove a lab endpoint is privileged, then test it as a low-privileged user.
  • Requirements: Using the provided secure/insecure lab app, call one admin function first with an admin token (expect 200 — baseline that the function is real), then with a user token. Record both status codes.
  • Input/Output: Input = two curl calls; output = a two-line status comparison.
  • Constraints: Localhost only; change only the token between calls.
  • Hints: Use -i or -w "%{http_code}" to read the status.
  • Concepts: Same-request-two-identities comparison, evidence capture.

Intermediate 1 — Method-swap test and fix.

  • Objective: Find and close a verb-level BFLA.
  • Requirements: On a route the UI only GETs, try PUT/DELETE as a normal user in the lab. If one succeeds, add a per-route+method role guard, then re-run to show it now returns 403 for the user and 200 for an admin.
  • Constraints: Lab only; reset seeded data afterward.
  • Hints: Enumerate every method your framework registers for the path.
  • Concepts: Method-swap pattern, deny-by-default guard, mitigation verification.

Intermediate 2 — Write the detection.

  • Objective: Turn logs into an alert.
  • Requirements: Ensure the lab app logs authz_allow/authz_deny with user id, route, method, and required role (never the token). Generate a small burst of user-token requests to /admin/*, then write a grep/query that flags a non-admin principal producing many denies, and separately flags any privileged route returning 200 to a non-admin.
  • Input/Output: Input = log file; output = the matching lines plus your alert rule.
  • Constraints: Log user id, not credentials; scrub the log when done.
  • Concepts: Detection & logging, false-positive reasoning (service accounts, shared IPs).

Challenge — Role x endpoint matrix and remediation.

  • Objective: Systematically prove authorization holds across a small API.
  • Requirements: For 4-5 lab endpoints and 2 roles (user, admin), build an expected results matrix (200/403 per cell). Test every cell as each identity in an authorized harness. For every cell that violates the expectation, apply a deny-by-default fix, then re-run the entire matrix to show all cells now match expectations. Write it up with the finding template (title, severity justified by exploitability/impact, affected component, preconditions, safe reproduction, evidence, impact, likelihood, remediation, retest).
  • Constraints: Lab only; disposable data; full cleanup/reset afterward; do not claim the API is "secure" — claim the tested controls hold.
  • Hints: Baseline each function as admin before judging user results; a 403 that should be 200 is a fix that's too strict, not a pass.
  • Concepts: RBAC matrix testing, deny-by-default, mitigation verification, professional reporting.

Summary

Main concepts. BFLA (Broken Function Level Authorization, OWASP API5:2023) is a missing or incorrect role/function check: the API authenticates who the caller is but never authorizes whether their role may run this action. It is the vertical/function sibling of BOLA (the horizontal/object bug from your prereq) — BOLA asks "whose object?", BFLA asks "whose job?". Three patterns dominate: hidden admin endpoints, HTTP method swaps, and privilege-unlocking parameters.

Key commands/structure. Test by sending the same privileged request under two identities and comparing (curl -i -X DELETE -H "Authorization: Bearer $TOKEN" http://localhost:.../admin/...): admin should get 200, a normal user must get 403. Fix with a server-side, per-route+method require_role/policy guard that runs before the handler and denies by default. Verify the fix by proving it rejects the low-privileged caller (403), accepts the authorized one (200), and treats no-token as 401.

Common mistakes. Mistaking a valid token for permission; enforcing only in the UI; gating one verb but not the dangerous ones; allow-by-default routing; trusting a client-supplied role. Decoding a token is not verifying entitlement, and passing a scanner never proves authorization is correct.

What to remember. Authenticate and authorize; deny by default; enforce on the server per route and method; derive role from the verified session, never from input. Log allow/deny decisions with user id (never tokens), alert on non-admins hitting privileged routes, and only ever test systems you own or are authorized to test — labs on localhost/containers/CTF, with a cleanup/reset step. Nothing is ever "completely secure"; you prove specific controls hold and retest to close the finding.

Practice with these exercises