Back to Cheat Sheets

🔓 Broken Object Level AuthorizationAPI 2019

OWASP API Security Top 10 2019 - API1

CRITICAL RISK

📋 What Is It?

Broken Object Level Authorization (BOLA) is the failure to check that the authenticated caller is actually entitled to the specific object they asked for. The endpoint takes an identifier from the request - a path segment, a query parameter, a body field - and loads that record without ever asking "does this object belong to this caller?". Authentication answers who you are; object level authorization answers which rows you may touch. BOLA is what happens when an API only does the first.

It ranked #1 in 2019 and stayed #1 in 2023, because it is both the easiest flaw to exploit - change a number in a URL - and the hardest to catch with scanners, which cannot know whose data /orders/1043 should be.

API1 OWASP Rank 2019
Every Object-Bearing Route
Trivial Exploitability

⚠️ Common Exploits

  • ID Enumeration: GET /api/orders/1042 → 1043, sequential walk of every record
  • Client-Supplied Identity: server trusts user_id from the body or query instead of the token
  • Nested Objects: parent checked, child (/accounts/9/cards/77) is not
  • Write Paths Forgotten: GET is guarded, PUT/DELETE on the same id is not
  • Second Channel: the web route checks ownership, the mobile or partner route does not
  • Export & Batch Endpoints: report/bulk routes accept arbitrary id lists

🔴 Attack Flow

1. Register a legitimate account and capture normal traffic
↓
2. Identify every request carrying an object id
↓
3. Replace the id with another user's (increment, or harvest from a listing)
↓
4. Observe a 200 with someone else's data - the check was never made
↓
5. IMPACT: Script the range and exfiltrate every record in the table!

❌ Vulnerable Code

# Bad: authenticated, but the object is never tied to the caller @app.route('/api/orders/<int:order_id>') @requires_auth # ← proves WHO, not WHAT def get_order(order_id): order = Order.query.get(order_id) ← any id, any caller return jsonify(order.to_dict()) // Bad: identity taken from the request body app.post('/api/transfer', async (req, res) => { const { fromAccountId, amount } = req.body; ← attacker picks the source account await debit(fromAccountId, amount); }); // Bad: parent checked, nested child is not app.get('/api/accounts/:accId/cards/:cardId', async (req, res) => { await assertOwnsAccount(req.user, req.params.accId); res.json(await Card.findById(req.params.cardId)); ← card may belong elsewhere });

✅ Secure Code

# Good: the ownership predicate is part of the query itself @app.route('/api/orders/<int:order_id>') @requires_auth def get_order(order_id): order = Order.query.filter_by(id=order_id, user_id=g.current_user.id).first() if order is None: return jsonify(error='Not found'), 404 # 404, not 403 - do not confirm existence return jsonify(order.to_dict()) // Good: identity comes only from the verified token app.post('/api/transfer', async (req, res) => { const account = await Account.findOne({ id: req.body.fromAccountId, ownerId: req.user.sub }); if (!account) return res.sendStatus(404); await debit(account.id, req.body.amount); }); // Good: one central check, applied to every object at every depth const authorize = (user, action, obj) => policy.can(user, action, obj) || notFound(); authorize(req.user, 'read', card); authorize(req.user, 'read', card.account); # Good: unguessable identifiers reduce the blast radius (defence in depth, not the control) id = uuid.uuid4()

✓ Prevention Checklist

  • Derive the caller's identity only from the verified session or token
  • Check ownership/entitlement on every object, on every request
  • Push the check into the data-access layer so it cannot be forgotten
  • Use a single, central authorization policy - never per-handler ad hoc code
  • Validate nested and related objects, not just the top-level one
  • Cover write and delete paths, not only reads
  • Return 404 rather than 403 to avoid confirming an object exists
  • Prefer random UUIDs over sequential integers (defence in depth)
  • Apply the same rules to bulk, export, and GraphQL resolvers
  • Write automated tests that fetch another tenant's object and expect 404

🔍 Detection & Tools

Testing:

Burp Suite (Autorize) OWASP ZAP Postman/Newman ffuf Schemathesis

Monitoring:

API gateway logs SIEM 403/404 spikes Rate anomaly rules

How to Test:

  • Create two accounts; replay user A's requests with user B's token
  • A 200 means BOLA; a 404/403 means the check exists
  • Walk sequential ids and watch for successful responses
  • Repeat for PUT, PATCH, DELETE, and bulk endpoints
  • Try nested resources whose parent you legitimately own

🌍 Real-World

These are documented incident classes - recurring patterns rather than single CVEs.

  • Sequential id scraping: a mobile back end exposes /api/v1/users/{id}; a script walking the range harvests names, emails and phone numbers for the whole user base in hours.
  • Client-supplied account identifiers: a fintech endpoint accepts the source account in the request body, letting an attacker move funds from accounts they do not own.
  • Documents by direct reference: an e-signature or invoicing API returns any document id to any authenticated user, exposing contracts across tenants.
  • Second-channel gaps: a hardened web API sits alongside a legacy partner API on the same data with no per-object checks at all.

📌 Quick Tips

  • DO NOT trust an id, tenant, or role sent by the client
  • DO NOT assume authentication implies authorization
  • DO NOT rely on unguessable ids as the control
  • DO scope every query by the caller's identity
  • DO centralise the policy and reuse it everywhere
  • DO test cross-tenant access in CI on every build

📜 Compliance

Related Standards:

  • OWASP API Security Top 10 2019 - API1
  • Maps to API1:2023 (unchanged, still #1)
  • CWE-639 Authorization Bypass Through User-Controlled Key
  • CWE-284 Improper Access Control
  • CWE-566 Authorization Bypass Through User-Controlled SQL Primary Key