Skip to content

OWASP API Top 10 (2019) — Drill Notes

Walking the 2019 list category by category. Each entry: what it is, how it differs from neighbours, how you test it.

API1: Broken Object Level Authorization (BOLA)

Section titled “API1: Broken Object Level Authorization (BOLA)”

Essentially IDOR reframed for APIs. Same root cause — the server authenticates you, then fails to check that this object is yours.

GET /api/users/123 ← your own ID (from your profile)
GET /api/users/124 ← swap it → someone else's record returned
IDOR (web era) BOLA (API)
Framing web app, page/param reference API endpoint + resource ID
“Direct” reference assumed? yes — guessable IDs, filenames, paths no
Where it lives A4:2013 → A01:2021 Broken Access Control API1 2019, still API1 2023
Scope any unauthorized object access specifically missing object-level authorization

The critical difference: IDOR implied guessable references, so “use UUIDs” became folk advice. BOLA explicitly covers unguessable IDs you obtained legitimately — a listing endpoint returns objects you shouldn’t see, you take an ID from it, then swap. Random IDs are defense in depth, not a fix.

  • APIs expose IDs as a first-class interface — that’s the design
  • Multiple clients (web, mobile, third-party) → inconsistent enforcement across routes that “should” be equivalent
  • No UI hiding the IDs; JSON hands you raw values
  • Clients cache objects and replay them
  • Trivially automatable: iterate IDs, harvest
BOLA (API1) BFLA (API5)
Level object — this record function — this endpoint/action
Example GET /users/124 as user 123 DELETE /users/124 as a normal user
Question “may I touch this object?” “may I perform this action at all?”
BOLA = /api/orders/{id} with someone else's id
BFLA = /api/admin/users called with a non-admin token
1. Log in as a low-privilege user; capture every request
2. Enumerate IDs — sequential integers, and UUIDs harvested from list endpoints
3. Swap for another user's object → 200 with their data = BOLA
4. Repeat across methods (GET/PUT/PATCH/DELETE) — authorization is often
enforced on read and forgotten on write
5. Test horizontally (peer user) AND vertically (other tenant/org)

Step 4 is why write methods matter: read-only checks are common, write checks are skipped.

✗ never authorize from the client-supplied ID
✓ derive ownership from the session/token subject, then filter by it
✓ one centralized per-object check — not copy-pasted per route
✓ enforce across every method and every route variant (/v1, /v2, legacy)

The one category where the user is the attack surface. Everything else targets objects, functions, or data; API2 targets the act of proving identity.

IN who are you? credentials, tokens, sessions, reset flows
OUT what may you do? → that's BOLA (API1) and BFLA (API5)

API2 is authN failing. BOLA, BFLA, and property-level are authZ failing. See the authN vs authZ page.

Mechanism How it breaks
Password login credential stuffing, brute force, no lockout, weak policy, timing / user enumeration
Password reset guessable token, token in URL → Referer + logs, no expiry, not single-use, host-header injection
API keys in URL query (logged by proxies), leaked in JS/mobile/git, no expiry, no rotation, one key shared by all users
Session predictable IDs, no rotation on login, no invalidation on logout, missing HttpOnly/Secure/SameSite
JWT alg:none, weak HMAC secret, kid injection, unchecked exp, no revocation
Basic Auth base64 assumed encrypted, password resent every request, no logout story
OAuth redirect_uri manipulation, missing state, implicit-flow leakage
MFA absent, bypassable by response manipulation, or enforced on only one endpoint
Brute force = many passwords against ONE account
Credential stuff = ONE known password against MANY accounts (leaked lists)

Stuffing is what kills real APIs. It is low-noise (each attempt hits a different account, so per-account lockout never fires), high-yield (password reuse), and distributed.

Per-account lockout does not stop stuffing. You need per-IP rate limits, device fingerprinting, or breach-list checks.

2. Password reset — the most-missed surface

Section titled “2. Password reset — the most-missed surface”
✗ token = user_id, a timestamp, or an incrementing value
✗ token sent in the URL → lands in Referer, proxy logs, browser history
✗ token reusable and/or never expires
✗ host-header injection: the reset link is built from the Host header
→ attacker sets Host to their own domain and harvests the token
✗ user enumeration: different response for "email not found" vs "sent"

Enumeration is the entry point for stuffing — harvest valid accounts from reset/login responses first, then stuff.

✗ no expiry
✗ logout only clears the client; the server-side token stays valid
✗ password change does not invalidate existing tokens
✗ refresh token with no rotation and no revocation

Test: log in, capture the token, log out, replay the token. Works = API2 finding. Then change the password and replay again.

Every JWT failure is an API2 finding — full detail on the JWT page:

alg:none → forge any identity
weak HMAC secret → offline brute force (hashcat -m 16500)
kid injection → path traversal / SQLi into the key lookup
exp unchecked → token valid forever
aud / iss unchecked → a token minted for ANOTHER app is accepted

That last one is the subtle killer: a token issued for a different service in the same ecosystem gets accepted here.

1. Enumerate users → reset/login responses that differ for known vs unknown
2. Rate limit → 100 rapid logins, watch for 429 or lockout
3. Stuffing → many accounts, ONE common password, distributed source
4. Reset flow → request a reset; is the token guessable? in the URL?
single-use? expiring?
5. Token replay → log out, replay the token; change password, replay again
6. Token in transit → any token in a URL, query string, or Referer?
7. JWT → decode; test alg:none; brute the secret; check exp/aud/iss
8. Method bypass → /login is rate-limited but /api/v1/login is not

Step 8 recurs constantly — protection applied to the current route and forgotten on legacy variants (ties into API9 Improper Assets Management).

✓ centralize authentication — one implementation, not per-route copies
✓ rate limit per IP AND per account, on reset/OTP too, not just login
✓ MFA on sensitive actions
✓ tokens: short-lived, rotated, revoked on logout AND on password change
✓ reset tokens: random 128-bit+, single-use, short expiry, in the BODY not the URL
✓ never expose tokens in URLs
✓ verify JWTs: allow-listed alg, checked exp/aud/iss, long secret
✓ constant-time comparison; identical responses for unknown user vs wrong password

Auth-specific rate limiting genuinely sits in both:

API2 (2019) → "no rate limiting on auth endpoints" is listed as an auth failure
API4 (2019) → "Lack of Resources & Rate Limiting" is its own category

Exam distinction:

API2 → limiting on AUTH operations specifically (login, OTP, reset)
API4 → limiting on ANY resource-consuming operation

Brute-forcing /login with an authentication framing → API2. DoS or enumeration through a general endpoint → API4.

2023 status: API2 stays “Broken Authentication” at #2 — one of the three (with BOLA and BFLA) that survive both editions because they are architectural, not patchable implementation bugs.

The API hands over more than the client needs and trusts the UI to hide the rest.

Developer: "the app only renders name and avatar"
Reality: the JSON body contains everything
Attacker: reads the raw response — never touches the UI

Client-side filtering is not security. Whatever is transmitted has already leaked.

Cause What happens
Serializing the ORM/DB model directly every column ships, needed or not
“Just return the object” no response schema, no DTO
Backwards compatibility old clients consume old fields, so fields are never removed
Shared endpoint across clients web needs more than mobile → mobile receives web’s payload
Verbose/debug mode left on stack traces, config, internal IDs
Generic list/detail endpoints detail returns full records, list returns nearly as much
password hashes, reset tokens, session IDs, API keys
isAdmin, role, permissions, internal flags
internal notes / admin comments about the user
soft-delete flags, internal IDs, DB timestamps
other users' PII inside nested objects
pricing / cost / margin fields
GPS coordinates, IP addresses, device fingerprints

The nested case is the sneaky one: /api/orders legitimately returns your order, but each order embeds a full user object carrying fields you should never see.

Question Example
BOLA (API1) right fields, wrong object? GET /users/124 as user 123
API3 (2019) right object, too many fields? GET /users/123 returns password_hash, ssn, isAdmin
Mass Assignment (API6) can I write fields I shouldn’t? PATCH /users/me {"role":"admin"}
BOLA = object-level authz missing
API3 = property-level authz missing (READ side)
Mass Assignment = property-level authz missing (WRITE side)

That is why 2023 merged them: Excessive Data Exposure + Mass Assignment → API3 Broken Object Property Level Authorization (BOPLA). Read and write are the same defect viewed from two sides.

1. Ignore the UI entirely — read raw JSON bodies
2. Diff: what the screen renders vs what the response contains
3. Hunt for the field names listed above
4. Compare the DETAIL endpoint against the LIST endpoint — detail is usually fatter
5. Check nested objects: does an embedded user/org object carry extras?
6. Compare across clients — mobile API vs web API for the same resource
7. Force errors and read the verbose response
8. Send extra params (?include=all, ?verbose=1, ?fields=*)

Step 4 matters — teams often tighten the list serializer and forget the detail one.

Same defect, different mechanism:

- introspection exposes the whole schema (types + fields unreachable via REST)
- "did you mean" suggestions leak field names for typos
- request fields you can see but should not read
- nested traversal: user → orders → payments → other users

Fix: disable introspection in production and authorize at the field level, not just the query level.

✓ explicit response schemas / DTOs — serialize on an allow-list, never the raw model
✓ never bind the ORM entity straight to the response
✓ a public "view" object distinct from the internal entity
✓ strip sensitive fields centrally at the serializer layer
✓ authorize per-field where data is role-dependent
✓ disable verbose errors in production
✓ review nested serializers — they leak by aggregation

The rule: the output contract must be a deliberate, minimal, named shape — not “whatever this object happens to contain today.”

It is invisible in testing. The UI looks correct, there is no error and no anomaly — the extra data sits silently in the response body. Unless someone reads the raw JSON, nothing signals a problem.

The full 2019 name includes Resources — that half matters, and it is what 2023 expanded into.

Rate limiting = too many requests over time (the time dimension)
Resources = one request too expensive (the size / depth dimension)

Same root cause: no accounting of per-request cost. A single 100MB upload can hurt more than 10,000 small calls, so the category covers both.

Dimension Missing control
Request count no throttling on login, OTP, reset, search, export
Payload size no cap on upload or JSON body
Response size pagination with no maximum page size
Execution time no timeout — ?sleep=3600, slow queries
Query depth unbounded GraphQL nesting, expensive filters
Upstream fan-out one request triggers 10+ backend calls
Storage unlimited uploads, no quota
1. Brute force / stuffing → ties into API2
2. Enumeration → harvest IDs, emails at scale
3. Flood / DoS → raw volume
4. COST AMPLIFICATION → economic DoS
5. Resource exhaustion → giant image, decompression bomb, ReDoS
6. Unbounded pagination → ?limit=1000000
7. GraphQL complexity bomb → nested query explodes exponentially
8. Long-running operations → expensive filters, report generation

Cost amplification is the interesting one:

attacker sends: 1 cheap request
server does: S3 scan + 3 upstream API calls + ML inference + full table scan
result: attacker pays ~$0, you pay per-invocation forever

That is why 2023 renamed the category — it is not just “too many requests”, it is per-request economics.

ReDoS belongs here too: a catastrophic regex like (a+)+$ lets one tiny request consume a CPU core. Shield is blind to it — it is L7 application code, not network flood.

Finding a limit is not enough. Test whether it can be evaded:

IP-based → spoof the header the limiter trusts:
X-Forwarded-For: <random>
X-Real-IP, X-Client-IP, True-Client-IP, Forwarded
Path-based → vary the path, same handler:
/api/login vs /API/login (case)
/api/login vs /api/login/ (trailing slash)
/api//login (double slash)
/api/login%2f (encoded)
/api/login?a=1 (random query param)
Route variants → /v1/login vs /v2/login vs /api/v2/login (ties to API9)
Method override → X-HTTP-Method-Override: POST
Identity rotation → multiple API keys, multiple accounts
Protocol trick → IPv6 vs IPv4 when keyed on address
Distribution → rotating proxies

Path variation is the most common real finding — the limit is keyed on the URL string while the router normalizes to the same handler. Legacy-route bypass (/v1 unthrottled, /v2 throttled) is the same class as API2 step 8.

- send 100+ rapid requests, watch for 429 or 503
- read headers: X-RateLimit-Limit, X-RateLimit-Remaining, Retry-After
- probe the boundary: after a 429, does the counter reset on a fixed window
or a sliding one? (fixed window = burst allowed at the boundary edge)
- distinguish lockout from throttle (account lock is a different control)
- check whether the limit is per-IP or per-account — then rotate the other one
API2 → limiting on AUTH operations (login, OTP, reset)
API4 → limiting on ANY resource-consuming operation

Brute-forcing /login framed as authentication → API2. Flooding a search or export endpoint → API4.

✓ limit per IDENTITY (user + API key + IP), not just globally
✓ limit at multiple layers — gateway AND application
(gateway alone is bypassable with the path variants above;
application alone is bypassable with distributed load)
✓ token bucket or sliding window, not naive fixed counters
✓ separate limits for expensive operations
✓ cap payload size, page size, query depth, execution time
✓ timeouts on every upstream call
✓ GraphQL: depth + complexity limits
✓ cost-weighted quotas for third-party-billed operations
2019: Lack of Resources & Rate Limiting
2023: Unrestricted Resource Consumption

Why renamed: broadened from “rate” to all resource cost — CPU, memory, bandwidth, storage, and third-party spend. The framing shifted from “you may not send too many requests” to “a single request may not cost too much.” The 2023 edition explicitly calls out third-party paid services, which is the cost-amplification angle.

API5: Broken Function Level Authorization (BFLA)

Section titled “API5: Broken Function Level Authorization (BFLA)”

Reach administrative or privileged functions as a normal user. See the BOLA/BFLA table above.

- call /admin/* endpoints with a regular token
- swap the HTTP method (GET allowed, DELETE not checked)
- guess alternate paths (/users vs /admin/users vs /internal/users)
- use the admin route from a different API version still deployed

Bind client-supplied JSON straight into the model — attacker sets fields the API never intended to expose.

PATCH /api/users/me
{ "name": "Fitra", "role": "admin", "balance": 999999, "email_verified": true }
↑ these get written if the object is bound without an allow-list

Test: add fields to every write request — role, isAdmin, verified, balance, ownerId. Watch the response and a subsequent GET.

- default credentials on admin/logging interfaces
- CORS: Access-Control-Allow-Origin: * with credentials
- verbose errors / stack traces in responses
- missing security headers
- unpatched or debug endpoints left enabled
- TLS misconfig, cloud storage left public

SQLi, NoSQLi, command injection, LDAP — classic injection reaching through API inputs.

{"email": {"$ne": null}} ← NoSQL operator injection
email=a' OR '1'='1 ← SQLi in a JSON string field

Also worth noting: XXE on SOAP/XML endpoints — see the REST vs SOAP page.

Old and undocumented versions still live and less protected.

/api/v1/... ← still deployed, still accepts the old token, no rate limit
/api/v2/... ← current, hardened
→ attacker uses v1

Also: staging/debug hosts, .env, .git/config, /phpinfo.php reachable. (Exactly the paths you saw being fuzzed through Caido.)

Test: probe version paths, IP-literal hosts, and non-production subdomains.

Attacks are invisible — no logging, no alerting, no detection path.

- enumeration and brute force go unrecorded
- no alert on anomalous access patterns
- logs lack the identity that made the request

Primarily a defensive finding, but it’s what lets every other category go unnoticed.

2019 2023 Change
API3 Excessive Data Exposure merged into API3 Broken Object Property Level Authorization 2019 split read-exposure from writable-fields; 2023 fuses both into property-level authz
API6 Mass Assignment merged into API3 mass assignment is one form of property-level write abuse
API4 Lack of Resources & Rate Limiting → Unrestricted Resource Consumption broadened from rate limits to all resource cost (payload size, CPU, third-party spend)
API8 Injection removed generic web risk, not API-specific
API10 Insufficient Logging & Monitoring removed generic observability, not API-specific
— NEW: API6 Unrestricted Access to Sensitive Business Flows business-logic abuse (stock buyout, coupon farming, scalping)
— NEW: API7 SSRF API fetches a user-supplied URL → internal services, cloud metadata
— NEW: API10 Unsafe Consumption of APIs blindly trusting third-party API responses

What survives both versions: BOLA, Broken Auth, BFLA. They persist because they are architectural — not implementation bugs you can patch away. If you learn only three, learn those.

  • OWASP API Security Top 10 — 2019 and 2023 editions
  • CAPIJ/CAPIE course: chapter 3 (OWASP 2019 articles) and chapter 4 (2023 practical demos)