L39 · The OWASP API Security Top 10
Module 6: API Security · ~20 min · Lesson 39 of 71
🎯 What you'll learn
- Why APIs got their own OWASP list, separate from the web Top 10 (L29)
- The ten risks of the 2023 edition, grouped into four questions you can remember
- What changed from the 2019 edition, and what was dropped (but not solved)
- How to review one endpoint against the whole list in ten minutes
🔥 Why this matters
Picture a customer portal that passes its web pen test: no XSS, no injection, clean headers. But the real
product is api.example.com, called by the portal, the mobile app, two partners and an Airflow
job. Nobody asked whether
/v1/customers/C-2002/transactions checks that you are C-2002, or why /v0 is still routed.
Those are API questions, and they need an API list.
1. Why APIs got their own list
A classic web app decides what you see on the server and sends finished HTML. An API-first app sends raw JSON objects, and the client decides what to show.
SERVER-RENDERED WEB APP API-FIRST APP
browser any client: SPA, mobile app,
│ 1. GET /statement partner, curl, a script
▼ │ 1. GET /v1/accounts/ACC-1001
server loads data, filters ▼
it, renders HTML API returns the JSON object
│ 2. HTML with 3 fields │ 2. JSON with all 30 fields
▼ ▼
user sees what the client hides 27 fields;
server chose an attacker reads them all
That shift moves the risk:
- The client is attacker-controlled code. Anything the app hides, a script can still read or send.
- Every request names an object. IDs in paths and bodies are an API's normal grammar, so each one needs an authorization check.
- More surface. Many endpoints and versions, and callers that never touch a browser.
- APIs consume APIs. Your service is also a client of vendors and other teams.
Browser bugs like XSS and CSRF matter less here. Authorization, business logic and inventory matter
more, and scanners are weak at those: a scanner can't know who should own ACC-1001.
The OWASP API Security Top 10 first came out in 2019. The current edition is 2023, and IDs work like the web list: API1:2023. One difference from L29: OWASP's public call for data got no submissions, so the 2023 list was built from the project team's experience, review by API security specialists, and public comments on a release candidate. Treat it as expert consensus, not statistics.
2. The ten risks, as four questions
Learn it as four questions to ask of any endpoint:
1. WHO ARE YOU?
API2 Broken Authentication
2. WHAT MAY YOU TOUCH? (authorization)
API1 Broken Object Level Authorization
API3 Broken Object Property Level Authorization
API5 Broken Function Level Authorization
3. HOW MUCH, HOW OFTEN?
API4 Unrestricted Resource Consumption
API6 Unrestricted Access to Sensitive Business Flows
4. WHAT ELSE IS EXPOSED OR TRUSTED?
API7 Server Side Request Forgery
API8 Security Misconfiguration
API9 Improper Inventory Management
API10 Unsafe Consumption of APIs
Three of the ten are authorization, at three levels. One request can fail all three:
PATCH /v1/admin/accounts/ACC-1002 {"creditLimit": 900000}
└───┬────┘ └───┬───┘ └──────────┬──────────┘
│ │ │
FUNCTION (API5) OBJECT (API1) PROPERTY (API3)
may this caller is ACC-1002 may this caller
use admin this caller's? set creditLimit?
operations?
The tour, mapped to a typical API stack:
| Risk | What it looks like in practice | Course |
|---|---|---|
| API1 Broken Object Level Authorization (BOLA) | C-2001's token reads /v1/customers/C-2002/transactions |
L34, L40 |
| API2 Broken Authentication | JWT aud not checked; no attempt limit on POST /v1/otp/verify; API keys in URLs |
L21–L25 |
| API3 Broken Object Property Level Authorization (BOPLA) | Response includes fraudScore; a PATCH accepts "kycStatus": "VERIFIED" |
L41 |
| API4 Unrestricted Resource Consumption | ?limit=100000; a 50 MB body; each call triggers a paid SMS or bureau lookup |
L42 |
| API5 Broken Function Level Authorization (BFLA) | A customer token calls POST /v1/admin/fees/waive |
L40 |
| API6 Unrestricted Access to Sensitive Business Flows | Bots test stolen cards on "add card", or farm sign-up bonuses | Deep dive 1 |
| API7 Server Side Request Forgery | "Import statement from URL" reaches 169.254.169.254 |
L35 |
| API8 Security Misconfiguration | Actuator routed through Kong; permissive CORS; stack traces in 500s | L37, L38 |
| API9 Improper Inventory Management | /v0 still routed; a -dev host holding prod data |
Try it |
| API10 Unsafe Consumption of APIs | A KYC vendor's response stored unchecked; its redirects followed | Deep dive 2 |
Two pairs people mix up:
- API1 vs API5 is object vs function: "may I touch this account?" vs "may I call this operation at all?" L40 is about this pair.
- API4 vs API6 is cost vs purpose. API4 is what a request costs you (CPU, memory, money); API6 is what many cheap, valid requests do to the business. Rate limits help both, and fix neither.
3. What changed from 2019 to 2023
2019 2023
API1 BOLA ─────────────────────────▶ API1 BOLA (unchanged)
API2 Broken User Authentication ───▶ API2 Broken Authentication
API3 Excessive Data Exposure ──┬───▶ API3 Broken Object Property
API6 Mass Assignment ──────────┘ Level Authorization
API4 Lack of Resources & Rate ─────▶ API4 Unrestricted Resource
Limiting Consumption
API5 BFLA ─────────────────────────▶ API5 BFLA (unchanged)
API7 Security Misconfiguration ────▶ API8 Security Misconfig.
API9 Improper Assets Management ───▶ API9 Improper Inventory
Management
API8 Injection ────────────────────▶ (dropped)
API10 Insufficient Logging & ──────▶ (dropped)
Monitoring
NEW ▶ API6 Sensitive Business Flows
NEW ▶ API7 SSRF
NEW ▶ API10 Unsafe Consumption
- Merged by root cause. Reading a field you shouldn't and writing one are the same missing check at the property level (L41).
- Renamed to name the harm. "Lack of rate limiting" named a control; "unrestricted resource consumption" names the risk, including the bill for paid third-party calls.
- Dropped is not solved. The release notes don't say why Injection and Logging left. The common reading is that they aren't API-specific and the web Top 10 covers them (A05:2025 and A09:2025). The NoSQL injection checks from L31 still apply to every API.
🧪 Worked example: one endpoint, ten questions
A pen tester calls a (fictional) transactions-svc on EKS, behind Kong, with customer
C-2001's token:
GET /v1/customers/C-2002/transactions?limit=100000 HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOiJSUzI1NiIs... (sub: C-2001)
HTTP/1.1 200 OK
{"items": [{"txId": "TX-90311", "amount": -42.10,
"merchant": "Example Coffee",
"cardNumber": "4111111111111111",
"fraudScore": 0.12}, ...],
"count": 100000}
Walk the list, one question each:
| Risk | Question | Finding |
|---|---|---|
| API1 | Is C-2002 the caller? | No. BOLA: the path ID isn't checked against the token |
| API2 | Is the token verified (signature, iss, aud, exp)? |
Yes, in Kong and in Spring Security |
| API3 | Does the client need every field? | No. Full card number and fraudScore leak |
| API4 | Is limit capped? |
No. 100,000 rows in one call |
| API5 | Do other methods exist on this path? | DELETE .../transactions/{txId}, meant for ops, accepts customer tokens |
| API6 | Is this a sensitive business flow? | No; reading history isn't abusable in bulk beyond API1/API4 |
| API7 | Does it fetch URLs? | No |
| API8 | What does an error look like? | ?from=bad returns a Java stack trace |
| API9 | Which versions are live? | /v0/customers/... is still routed in Kong, with no owner |
| API10 | Does it call other APIs? | Merchant logos from a vendor, rendered unchecked |
Six findings, and a web scanner would likely have flagged only the stack trace. The fixes land in
different layers: the query and response DTO (API1, API3), a limit cap (API4), a route rule
(API5), and a Kong cleanup (API9). L40 to L43 take them one by one.
🛠 Try it (optional, ~5 min)
Inventory (API9) starts with what you publish. This script flags OpenAPI operations worth a
review. Save a sample spec as spec.json (made-up routes):
{"openapi": "3.0.3",
"security": [{"bearer": []}],
"paths": {
"/v1/customers/{customerId}/transactions": {"get": {}},
"/v1/admin/fees/{feeId}/waive": {"post": {}},
"/v0/customers/{customerId}": {"get": {"deprecated": true}},
"/v1/health": {"get": {"security": []}}}}
Save this as api_inventory.py:
import json, sys
spec = json.load(open(sys.argv[1]))
default = spec.get("security", [])
METHODS = {"get", "post", "put", "patch", "delete"}
for path, ops in spec["paths"].items():
for method, op in ops.items():
if method not in METHODS:
continue
flags = []
if not op.get("security", default):
flags.append("API2:no-auth")
if "{" in path:
flags.append("API1:id")
if "admin" in path or "internal" in path:
flags.append("API5:privileged")
if op.get("deprecated"):
flags.append("API9:deprecated")
print(f"{method.upper():6} {path:39} {' '.join(flags)}")
Run python3 api_inventory.py spec.json:
GET /v1/customers/{customerId}/transactions API1:id
POST /v1/admin/fees/{feeId}/waive API1:id API5:privileged
GET /v0/customers/{customerId} API1:id API9:deprecated
GET /v1/health API2:no-auth
Then run it on your own service's spec, on your own machine. Every API1 line needs an
ownership check, every API5 line a role rule, and every no-auth line a good reason. Then ask
the real API9 question: which gateway routes (Kong routes, in the example stack) aren't in any spec? Those are shadow APIs.
🧠 Key takeaways
- APIs hand raw objects to a client the attacker controls, so authorization and business logic dominate the risk, and scanners are weak at both.
- Remember the 2023 list as four questions: who are you, what may you touch, how much and how often, and what else is exposed or trusted.
- Authorization appears three times: object (API1), property (API3), function (API5).
- 2023 merged excessive data exposure and mass assignment into API3, and added business flows, SSRF and unsafe consumption. Injection was dropped from the list, not from reality.
- Review an endpoint by walking all ten; the fixes land in different layers.
📖 Words to know
| Term | Meaning |
|---|---|
| OWASP API Security Top 10 | OWASP's awareness list of the ten most critical API risks; current edition 2023 |
| BOLA | Broken Object Level Authorization (API1): acting on an object that isn't yours |
| BOPLA | Broken Object Property Level Authorization (API3): reading or writing fields you shouldn't |
| BFLA | Broken Function Level Authorization (API5): calling an operation your role shouldn't use |
| Sensitive business flow | A legitimate feature that hurts the business when automated at scale |
| Shadow API | An endpoint that's live but not in any inventory or spec |
| Zombie API | An old version that should be retired but still answers |
| Unsafe consumption | Trusting responses from third-party or internal APIs without validating them |
⏭ Next time
L40 goes deep on the top pair, BOLA and BFLA: the patterns that survive a first fix, and a deny-by-default design that makes both hard to forget.
Business-flow abuse (API6): when every request is valid
Most of the list is about bugs. API6 is different: the feature works exactly as designed, and that's the problem. OWASP's own examples include bots buying up limited stock and farming referral credits. Each request is authenticated, authorized and well-formed. The harm is in the volume and the intent.
What makes a flow "sensitive"
A flow is sensitive when using it at machine speed costs you money, stock, reputation or a regulator's attention. Deciding which flows qualify is a business decision first, which is why OWASP lists "identify the flows" as the business half of the fix. Fintech has plenty:
| Flow | Abuse | What it costs |
|---|---|---|
| Add or verify a card | Card testing: bots check stolen card numbers with small authorizations | Processing fees, chargebacks, card-network scrutiny |
| Send an SMS one-time code | SMS pumping (toll fraud): codes sent to premium or high-cost numbers the attacker profits from | The SMS bill |
| Open an account with a bonus | Bonus and referral farming with fake or synthetic identities | The bonuses, plus KYC costs |
| Get a quote or rate | Scraping your pricing | Competitive advantage |
| Micro-deposit account check | Testing which bank accounts are real | Fraud downstream |
Worked example: SMS pumping on account opening
Step 1 of a (fictional) account-opening flow is POST /v1/onboarding/otp with a phone number. It
needs no login, because the customer doesn't exist yet:
1. bot ──POST /v1/onboarding/otp {"phone": "+XX..."}──▶ Kong
2. Kong: per-IP limit ok (bot rotates 5,000 residential IPs)
3. onboarding-svc ──"send code"──▶ SMS provider
4. provider ──SMS──▶ premium-rate numbers in a country
you don't serve
5. carrier pays the number's owner a share; you pay the bill
every request: valid JSON, valid number, under rate limits
Nothing in steps 1 to 3 is a bug. The fix is a set of business rules on the flow, close to the code that spends the money:
- Only allow what you serve. If you onboard US customers, reject non-US numbers before sending.
- Limit on business keys, not just IPs. Codes per phone number, per number prefix, per device, and per hour across the whole flow (L42 covers the mechanics).
- Enforce the order of steps. Issue a short-lived flow token on the first page; the OTP call requires it, so a bot can't call step 1 in a loop without fetching step 0.
- Watch the cost metric. Alert when SMS spend per hour leaves its normal band (L62). This is how most teams notice first.
OWASP's engineering advice adds device fingerprinting, human detection, spotting non-human timing, and considering blocks on Tor exit nodes and known proxies. Be careful with that last one in banking: some legitimate customers use privacy tools, so prefer step-up checks over hard blocks.
Why gateways and WAFs miss it
A WAF looks for malicious content (L12), and there is none here. A per-IP rate limit at Kong sees 5,000 polite clients. Only the service knows that 4,000 different numbers in one country prefix were sent codes in ten minutes. That's why API6 controls usually live in the service or a fraud platform, with the gateway as a coarse first layer (L43).
🧠 Extra takeaways
- API6 risks are features working as designed, abused at machine speed.
- Naming the sensitive flows is a business decision; list them in your threat model (L05).
- Limit on business keys (phone, card, device, account), not just IP addresses.
- Watch the cost or business metric; it often moves before any security alert.
Unsafe consumption (API10): the APIs you call are input too
Teams validate what customers send them, then trust whatever a vendor's API sends back. API10 is that gap. If an attacker can't break your API directly, they can try to break, impersonate or manipulate something your API trusts: a KYC vendor, an FX rate feed, a partner's webhook, or another internal team's service.
What goes wrong
OWASP's API10 guidance boils down to a few failure modes:
- Following redirects blindly. The vendor (or someone who has compromised it) answers with a redirect to an attacker's host, and your client follows it, possibly re-sending sensitive data.
- Storing data without validating it. A malicious string lands in your database and becomes injection or XSS later (second-order, as in L30's deep dive).
- No limits. No timeout, no size cap: one slow or huge response ties up your workers (API4 in reverse).
- Weak transport.
verify=False"just for this vendor", or plain HTTP inside the VPC. - Trusting the vendor's security posture without ever assessing it (L13's Target breach began at a third party).
Worked example: an Airflow task that pulls FX rates
A daily Airflow task fetches exchange rates from a (fictional) vendor and writes them to MongoDB,
where payments-svc uses them to convert transfers.
# VULNERABLE
def load_rates():
resp = requests.get("https://rates.example.net/v2/latest")
rates = resp.json()["rates"] # any shape, any size
db.fx_rates.insert_one({"date": today(), "rates": rates})
Four problems: Python requests has no default timeout, it follows redirects on GET by default,
the body size is unbounded, and a rate of 0, -1 or "1e9" goes straight into payments math.
# FIXED
from decimal import Decimal
from pydantic import BaseModel, condecimal
Rate = condecimal(gt=Decimal("0.0001"), lt=Decimal("100000"))
class RatesResponse(BaseModel):
base: str
rates: dict[str, Rate]
def load_rates():
resp = requests.get("https://rates.example.net/v2/latest",
timeout=(3, 10), # connect, read
allow_redirects=False) # no surprises
resp.raise_for_status() # 3xx -> error
if len(resp.content) > 256_000:
raise ValueError("rates response too large")
data = RatesResponse.model_validate_json(resp.content)
if data.base != "USD" or not {"EUR", "GBP"} <= data.rates.keys():
raise ValueError("unexpected rates payload")
check_daily_move(data.rates, max_change=Decimal("0.05"))
db.fx_rates.insert_one({"date": today(),
"rates": {k: str(v) for k, v
in data.rates.items()}})
Notes on the fix:
raise_for_status()raises on 4xx and 5xx only. With redirects off, a 3xx would pass silently, so in real code also checkresp.status_code == 200.- The size check here runs after download. For a hard cap, stream the response
(
stream=True) and stop reading past the limit. check_daily_move(your own function) compares against yesterday's rates and fails the task if any currency moved more than 5%. A failed task pages someone; a silently wrong rate moves money.pydanticv2 syntax is shown (model_validate_json); v1 usedparse_raw.
A checklist for every integration
| Check | Why |
|---|---|
| TLS verified, ideally to a pinned host name list | No one in the middle (L10) |
| Timeouts, size caps, retries with backoff | A sick vendor can't take you down |
| Redirects off, or an allow-list of hosts | No data sent to a surprise host |
| Schema validation with business bounds | Bad data fails loudly, at the edge |
| Encode on output, parameterize on storage | Vendor strings can't become code (L30–L32) |
| Credentials scoped to this vendor, rotated | A leak at one vendor stays small (L19) |
| A named owner and a review date | Vendor integrations belong in your inventory (API9) |
🧠 Extra takeaways
- Data from an API you call is untrusted input, even from a paying vendor or another internal team.
- Set timeouts, size caps and redirect rules explicitly; library defaults are tuned for convenience.
- Validate shape and business sense (bounds, day-over-day change) before data reaches money.
- Fail the job loudly rather than storing something wrong quietly.
The quiz for this lesson and the Tests section need JavaScript.