API Security · intermediate · ~10 min
**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.
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.
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.
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.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.
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.
| 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.
(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.
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.
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
role=user principal to an /admin route or a dangerous verb — especially one that returned 200 instead of 403.)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.
Authorization bugs come in two flavors. It helps to keep them straight:
BFLA (Broken Function Level Authorization) happens when a lower-privileged user invokes an endpoint or action reserved for an admin.
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).
/admin and /internal, plus management verbs.GET, but the API might still accept PUT or DELETE.Enforce function-level authorization on the server:
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.
# 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.
# 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.
# 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.
Walking through the secure handler for the request DELETE /api/v1/users/5002 sent with Authorization: Bearer tok-user:
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.current_user() reads the Authorization header, strips Bearer , and looks up tok-user -> (4001, "user"). Authentication succeeds; user is not None._uid = 4001, role = "user".role != required -> "user" != "admin" -> True. The guard takes the deny branch.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.
Mistake 1 — Treating a valid token as permission.
Mistake 2 — Enforcing authorization only in the UI.
Mistake 3 — Gating only the verb the UI uses.
GET /users/{id} but leave PUT/DELETE on the same path unguarded.Mistake 4 — Allow-by-default routing.
Mistake 5 — Trusting a client-supplied role.
role from the request body or a ?role=admin query parameter.role/is_admin from request.*; that is a red flag.When a BFLA test or a fix behaves unexpectedly, work through these:
user is not None."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.Bearer prefix or a header-name typo. Print the raw Authorization header the server received./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).Questions to ask when it fails:
Security & safety — detection, logging, and authorization for BFLA.
What to log on every privileged request (allowed and denied):
DELETE /api/v1/users/5002).authz_allow / authz_deny, with the required role).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:
user generating authz_deny on /admin, /internal, or dangerous verbs — especially many in a short window (enumeration).GETs.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:
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.
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.
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.
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).Beginner 2 — Baseline then diff.
-i or -w "%{http_code}" to read the status.Intermediate 1 — Method-swap test and fix.
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.Intermediate 2 — Write the detection.
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.Challenge — Role x endpoint matrix and remediation.
403 that should be 200 is a fix that's too strict, not a pass.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.