Your scanner gave you a green checkmark. Your API is still leaking every user's data.
That is the uncomfortable truth about the most common API flaw in the wild. Broken Object-Level Authorization (BOLA), also called IDOR, sits at number one on the OWASP API Security Top 10. It is not an exotic bug. It is a request that looks completely valid, from a logged-in user, hitting a normal endpoint, that just happens to return someone else's record. Signature-based scanners wave it through because nothing about the request is malformed. The authorization logic is simply missing.
The best way to understand a class of bug is to build it, break it, and then fix it. So that is what we are going to do. By the end you will have:
- A tiny, deliberately vulnerable REST API you can run locally.
- A walkthrough of four real API weaknesses, exploited the way an attacker would.
- A
pytestsuite that turns those exploits into regression tests for your CI pipeline. - The fixes, side by side with the vulnerable code.
Everything runs on your machine against your own app. No third-party targets, no live systems.
Prerequisites
You need Python 3.9+ and two packages:
pip install flask requests pytest
That is the whole toolchain. We will use requests to play attacker and pytest to lock in the fixes.
Step 1: Build the vulnerable API
Save this as app.py. It is a small user-and-orders service with an in-memory "database." Read the comments: each one marks a decision that a real team makes by accident under deadline pressure.
# app.py
from flask import Flask, request, jsonify
import secrets
app = Flask(__name__)
# --- Fake in-memory "database" ---
users = {
1: {"id": 1, "username": "alice", "email": "alice@example.com",
"role": "user", "password_hash": "pbkdf2:sha256:...alice",
"ssn": "111-11-1111"},
2: {"id": 2, "username": "bob", "email": "bob@example.com",
"role": "user", "password_hash": "pbkdf2:sha256:...bob",
"ssn": "222-22-2222"},
3: {"id": 3, "username": "carol", "email": "carol@example.com",
"role": "admin", "password_hash": "pbkdf2:sha256:...carol",
"ssn": "333-33-3333"},
}
passwords = {"alice": "alice123", "bob": "bob123", "carol": "carol123"}
orders = {
1: [{"id": 101, "item": "Laptop", "total": 1299}],
2: [{"id": 102, "item": "Headphones", "total": 199}],
3: [{"id": 103, "item": "Server rack", "total": 4999}],
}
tokens = {} # opaque_token -> user_id
def current_user():
raw = request.headers.get("Authorization", "").replace("Bearer ", "")
return users.get(tokens.get(raw))
@app.post("/login")
def login():
data = request.get_json(force=True)
username, password = data.get("username"), data.get("password")
user = next((u for u in users.values() if u["username"] == username), None)
if not user or passwords.get(username) != password:
return jsonify({"error": "invalid credentials"}), 401
token = secrets.token_hex(16)
tokens[token] = user["id"]
return jsonify({"token": token})
# VULN 1 + VULN 3: authenticated, but never checks the object belongs to the
# caller (BOLA), and returns the raw record including secrets (data exposure).
@app.get("/api/users/<int:user_id>")
def get_user(user_id):
if not current_user():
return jsonify({"error": "unauthorized"}), 401
user = users.get(user_id)
if not user:
return jsonify({"error": "not found"}), 404
return jsonify(user)
# VULN 2: forgot to require a token at all.
@app.get("/api/users/<int:user_id>/orders")
def get_orders(user_id):
return jsonify(orders.get(user_id, []))
# VULN 4: blindly merges the request body into the record (mass assignment).
@app.patch("/api/users/<int:user_id>")
def update_user(user_id):
if not current_user():
return jsonify({"error": "unauthorized"}), 401
users[user_id].update(request.get_json(force=True))
return jsonify(users[user_id])
if __name__ == "__main__":
app.run(port=5000, debug=True)
Start it:
python app.py
Leave it running in one terminal. We attack it from another.
Step 2: Grab a legitimate token
An attacker here is not an anonymous outsider. Most BOLA exploitation comes from a real, logged-in user poking at IDs that are not theirs. So we log in as Alice, a perfectly ordinary user.
curl -s -X POST http://localhost:5000/login \
-H "Content-Type: application/json" \
-d '{"username": "alice", "password": "alice123"}'
{ "token": "9f2c...your-token..." }
Everything from here uses Alice's token. She is authenticated. She is authorized to see her own data. Watch what else she can reach.
Step 3: Exploit BOLA (the #1 flaw)
Alice's own record is user ID 1. But the endpoint takes any ID. Let us ask for Bob's, ID 2:
# attack.py
import requests
BASE = "http://localhost:5000"
alice = requests.post(f"{BASE}/login",
json={"username": "alice", "password": "alice123"}).json()["token"]
h = {"Authorization": f"Bearer {alice}"}
# Alice (id 1) requests Bob's record (id 2)
print(requests.get(f"{BASE}/api/users/2", headers=h).json())
{
"id": 2, "username": "bob", "email": "bob@example.com",
"role": "user", "password_hash": "pbkdf2:sha256:...bob",
"ssn": "222-22-2222"
}
There it is. Alice just read Bob's account. Nothing about that request is malformed: valid token, valid route, valid ID. The server authenticated her and then never asked the one question that mattered: does this object belong to you? Swap 2 for 3 and she reads the admin. Loop from 1 to N and she scrapes the entire user table.
This is why BOLA slips past pattern matching. There is no payload to signature. The bug lives in the gap between authentication and authorization.
Step 4: Exploit broken authentication
Look at the orders endpoint. Someone shipped it in a hurry and forgot the token check entirely. No login required:
import requests
# No Authorization header at all
print(requests.get("http://localhost:5000/api/users/2/orders").json())
[ { "id": 102, "item": "Headphones", "total": 199 } ]
An anonymous request pulled Bob's order history. This is Broken Authentication, OWASP API number two. One missing decorator, one exposed dataset.
Step 5: Exploit excessive data exposure
Go back to the BOLA response and read it closely. Even when Alice requests her own record, the API hands back password_hash and ssn. The handler serialized the raw database row and shipped it over the wire. That is Excessive Data Exposure: the client filters the UI, but the API sent everything, and anyone reading the response body sees the lot.
Step 6: Exploit mass assignment
The PATCH endpoint takes your JSON and merges it straight into the record. It expects you to update your email. Nothing stops you from updating your role:
import requests
BASE = "http://localhost:5000"
alice = requests.post(f"{BASE}/login",
json={"username": "alice", "password": "alice123"}).json()["token"]
h = {"Authorization": f"Bearer {alice}"}
# Alice edits her own record... and quietly promotes herself
requests.patch(f"{BASE}/api/users/1", headers=h, json={"role": "admin"})
print(requests.get(f"{BASE}/api/users/1", headers=h).json()["role"])
admin
Alice is now an administrator. That is Mass Assignment: the client controlled a field the developer never meant to expose. Privilege escalation in one request.
Step 7: Turn the exploits into a test suite
Manual pokes are fun once. The real payoff is making them permanent. We encode the secure behavior we want as pytest assertions. Against the vulnerable app they fail, which is exactly what a good security test should do when the code is broken.
Save this as test_api_security.py:
# test_api_security.py
import requests
BASE = "http://localhost:5000"
def token_for(username, password):
r = requests.post(f"{BASE}/login",
json={"username": username, "password": password})
r.raise_for_status()
return r.json()["token"]
def auth(username, password):
return {"Authorization": f"Bearer {token_for(username, password)}"}
def test_bola_user_cannot_read_another_users_record():
# Alice (id 1) must not be able to read Bob (id 2)
r = requests.get(f"{BASE}/api/users/2", headers=auth("alice", "alice123"))
assert r.status_code == 403, "BOLA: cross-user object access must be forbidden"
def test_orders_endpoint_requires_authentication():
r = requests.get(f"{BASE}/api/users/2/orders") # no token
assert r.status_code == 401, "Broken auth: endpoint must require a valid token"
def test_response_hides_sensitive_fields():
r = requests.get(f"{BASE}/api/users/1", headers=auth("alice", "alice123"))
body = r.json()
for secret in ("password_hash", "ssn"):
assert secret not in body, f"Data exposure: {secret} leaked in response"
def test_role_cannot_be_mass_assigned():
h = auth("alice", "alice123")
requests.patch(f"{BASE}/api/users/1", headers=h, json={"role": "admin"})
role = requests.get(f"{BASE}/api/users/1", headers=h).json().get("role")
assert role != "admin", "Mass assignment: role must not be client-settable"
Run it with the vulnerable app still up:
pytest test_api_security.py -q
FFFF
4 failed in 0.12s
Four reds. Every one is a real vulnerability, described in plain language in the assertion message. This is the file you commit. It fails today, and it fails again the day someone reintroduces the bug.
Tip: in CI, start the app as a background step before
pytest, or swaprequestsfor Flask'sapp.test_client()so the suite is fully self-contained.
Step 8: Fix the code
Now we earn the green. Here are the four handlers, repaired.
BOLA plus data exposure. Add an ownership check, and serialize an explicit allow-list of fields instead of the raw row:
PUBLIC_FIELDS = ("id", "username", "email")
def public_view(user):
return {k: user[k] for k in PUBLIC_FIELDS}
@app.get("/api/users/<int:user_id>")
def get_user(user_id):
caller = current_user()
if not caller:
return jsonify({"error": "unauthorized"}), 401
if caller["id"] != user_id and caller["role"] != "admin":
return jsonify({"error": "forbidden"}), 403 # object-level authz
user = users.get(user_id)
if not user:
return jsonify({"error": "not found"}), 404
return jsonify(public_view(user)) # no secrets on the wire
Broken authentication. Require a token, and check ownership here too:
@app.get("/api/users/<int:user_id>/orders")
def get_orders(user_id):
caller = current_user()
if not caller:
return jsonify({"error": "unauthorized"}), 401
if caller["id"] != user_id and caller["role"] != "admin":
return jsonify({"error": "forbidden"}), 403
return jsonify(orders.get(user_id, []))
Mass assignment. Never merge raw input. Accept only fields the user is allowed to change:
EDITABLE_FIELDS = ("email",)
@app.patch("/api/users/<int:user_id>")
def update_user(user_id):
caller = current_user()
if not caller or (caller["id"] != user_id and caller["role"] != "admin"):
return jsonify({"error": "forbidden"}), 403
updates = {k: v for k, v in request.get_json(force=True).items()
if k in EDITABLE_FIELDS}
users[user_id].update(updates)
return jsonify(public_view(users[user_id]))
Restart the app and run the suite again:
pytest test_api_security.py -q
....
4 passed in 0.10s
Four greens. Your API now enforces authorization at the object level, requires authentication everywhere, keeps secrets out of responses, and refuses client-supplied privilege changes.
The gap that manual testing leaves
Here is the honest catch, and it is the reason API breaches keep happening at mature teams. The suite above tests the flaws you thought to test. It says nothing about the combinations you did not imagine.
Chain the four bugs from this tutorial and you do not have four medium findings. You have one critical path: an unauthenticated request enumerates orders, a BOLA read harvests the user table, and a mass-assignment PATCH escalates to admin. No single test asserts against that sequence, because writing it requires thinking like an attacker who moves through your app, not one who checks endpoints in isolation
That is the job ZeroThreat.ai is built for. Its engine does application-aware attack chain discovery: it walks complex, authenticated, multi-step workflows the way a real user would, links weaknesses like these into actual attack paths, and ranks each one by the business impact of what it reaches, so the account-takeover chain surfaces above the noise. Findings come validated with zero false positives, and each ships with the endpoint, parameters, evidence, and a fix. It does not replace your pentester or your pytest suite. It covers the ground neither of them was ever going to reach by hand.
Takeaways
- BOLA is an authorization gap, not a payload. Authenticate, then always ask whether the object belongs to the caller. Scanners cannot see this; you have to test it deliberately.
- Return allow-listed fields, never raw rows. Your API is the boundary. The client filtering the UI does not protect the response body.
- Accept allow-listed input, never merge raw JSON. Mass assignment is one forgotten field away from privilege escalation.
- Encode security expectations as tests. A failing security test is documentation of a real risk. Commit it, and it guards every future PR.
- Test the chains, not just the endpoints. The dangerous bug is usually the combination you did not think to check.
Clone the two files, break them, fix them, and keep the suite. Then point something application-aware at the whole app to find the paths you would have missed.
Have a favorite API footgun I did not cover here? Drop it in the comments, I read them all.
Top comments (0)