The OWASP API Security Top 10 is a separate list from the better-known OWASP Top 10 for web applications, and it exists because APIs fail differently. A server-rendered web page decides what to show you. An API hands the client raw objects and IDs and trusts the client to ask only for what it should. Most serious API breaches do not involve clever exploits. Someone changes an ID in a URL, adds a field to a JSON body, or calls an endpoint the mobile app never uses, and the server answers.
The current edition is the API Security Top 10 2023. OWASP published a 2025 update to the web application Top 10, but that did not replace the API list; the 2023 API edition is still current. This article goes through the ten risks in groups by where the fix belongs (authorization, authentication, consumption, outbound calls and inventory), with code for the controls that are easy to get subtly wrong and a test for each.
The ten risks and where each is fixed
Here are the ten risks with the place where each one is fixed. The ordering matters: the top of the list is mostly authorization. Authorization is the control a gateway cannot add for you, because only your code knows who owns which object.
| ID | Risk | In one sentence | Where the fix lives |
|---|---|---|---|
| API1 | Broken Object Level Authorization | A caller reads or changes an object by guessing or swapping its ID | Every data access, scoped to the caller |
| API2 | Broken Authentication | Weak login, token or credential handling lets someone become another user | Identity provider and token validation |
| API3 | Broken Object Property Level Authorization | Responses expose fields, or requests set fields, the caller should not touch | Explicit input and output schemas |
| API4 | Unrestricted Resource Consumption | No limits on size, rate or cost lets a caller exhaust resources or your bill | Gateway limits plus per-endpoint caps |
| API5 | Broken Function Level Authorization | A regular user can call admin or other privileged operations | Deny-by-default route policy |
| API6 | Unrestricted Access to Sensitive Business Flows | Legitimate endpoints automated at scale: scalping, spam, referral abuse | Flow-level controls and abuse signals |
| API7 | Server Side Request Forgery | The API fetches a URL supplied by the caller and reaches internal targets | Outbound allowlist and IP checks |
| API8 | Security Misconfiguration | Permissive CORS, verbose errors, missing headers, unpatched stacks | Hardened defaults, checked in CI |
| API9 | Improper Inventory Management | Old versions, debug and shadow endpoints nobody tracks | An API inventory tied to deployment |
| API10 | Unsafe Consumption of APIs | Trusting data from third-party APIs more than user input | Validate and bound everything inbound |
Authorization: objects, properties and functions
Take a concrete API: GET /api/v1/orders/{id} returns an order, PATCH /api/v1/orders/{id} updates it, and POST /api/v1/admin/refunds issues refunds. Three of the ten risks are about this API alone.
API1, object level. The vulnerable version checks that the caller is logged in and then loads the order by ID. Any authenticated user can read any order by counting through IDs. Switching to UUIDs makes IDs harder to guess, but it is not a fix, because IDs leak through logs, shared links and other responses. The fix is to scope every lookup to the caller, inside the query itself:
# Vulnerable: authenticated, but not authorized
def get_order(user, order_id):
return db.one("SELECT * FROM orders WHERE id = %s", order_id)
# Fixed: ownership is part of the lookup, and "not yours" looks the same as "missing"
def get_order(user, order_id):
row = db.one(
"SELECT * FROM orders WHERE id = %s AND tenant_id = %s AND customer_id = %s",
order_id, user.tenant_id, user.customer_id)
if row is None:
raise NotFound() # 404, not 403: do not confirm that the ID exists
return rowPutting the ownership condition in the query means a handler that forgets to check cannot leak data, and it also covers list and search endpoints, which are where BOLA most often hides. When the rule is richer than ownership (support staff of the same region, shared projects), move it into one policy function or engine and call it on every access; RBAC and ABAC covers the models.
API3, property level. Two problems share this entry. Excessive data exposure is when the handler serialises the whole database row, including internal_margin or another customer's email, and leaves it to the client to hide. Mass assignment is when the update handler copies the whole JSON body onto the model, so {"status": "refunded", "total": 0} is accepted alongside the delivery note the user meant to change. Both are fixed with explicit schemas in each direction:
from pydantic import BaseModel, ConfigDict
class OrderOut(BaseModel): # what a customer may SEE
id: str
status: str
items: list[dict]
total_cents: int
class OrderPatchIn(BaseModel): # what a customer may CHANGE
model_config = ConfigDict(extra="forbid") # unknown fields are an error, not ignored
delivery_note: str | None = None
def patch_order(user, order_id, body: dict):
order = get_order(user, order_id) # API1 check first
changes = OrderPatchIn(**body) # rejects "status", "total_cents", ...
order.update(changes.model_dump(exclude_unset=True))
return OrderOut.model_validate(order, from_attributes=True)API5, function level. The refunds endpoint must only be callable by people with refund rights. Hiding it from the regular app does not achieve that. The safe structure is deny-by-default: every route declares the permission it needs, and a test fails the build if any route has no declaration.
ROUTE_POLICY = {
("GET", "/api/v1/orders/{id}"): "orders:read:own",
("PATCH", "/api/v1/orders/{id}"): "orders:write:own",
("POST", "/api/v1/admin/refunds"): "refunds:issue",
}
def authorize(request, user):
need = ROUTE_POLICY.get((request.method, request.route_template))
if need is None:
raise Forbidden("no policy declared") # unknown route: deny
if need not in user.permissions:
raise Forbidden()
def test_every_route_has_a_policy(app):
missing = [r for r in app.routes() if (r.method, r.template) not in ROUTE_POLICY]
assert not missing, missing
Authentication
API2 covers everything that lets someone act as another user: login endpoints with no brute-force protection, password reset flows that can be enumerated, tokens accepted without checking signature, expiry, issuer or audience, API keys sent in URLs where they end up in logs, and long-lived credentials that cannot be revoked. For APIs, the most common failure is token validation. A service that decodes a JWT without verifying it, accepts alg: none, or accepts a token issued for a different service has broken authentication even if the identity provider is perfect.
Three rules cover most of this. Verify every token with a pinned algorithm and check iss, aud, exp and nbf (see JWT validation without the bypasses). Put rate limits and lockouts on the authentication endpoints specifically, because they are targeted far more than other endpoints. Use short-lived access tokens with refresh tokens that can be revoked, issued through a standard flow (OAuth 2.0 in depth).
Resource consumption and business flows
API4, unrestricted resource consumption. Every API has costs: CPU, memory, database time, outbound calls, and increasingly paid third-party calls such as SMS, email or model inference. If a request can make you spend without a limit, someone will eventually make it spend. The controls are layered:
- Rate limits per credential and per IP at the gateway, with tighter limits on expensive routes (rate limiter architecture).
- Caps inside each handler: a maximum page size (
limitclamped to, say, 100), maximum request body size, maximum upload size, maximum batch length, and timeouts on every downstream call. - For GraphQL and other query languages: limits on query depth and complexity, because a single request can be arbitrarily expensive.
- Spending limits on paid integrations, such as a daily SMS budget per account and per tenant, with an alert before the limit is reached.
API6, sensitive business flows. This entry covers the case where nothing is technically wrong. The checkout, sign-up or referral endpoint works exactly as designed, and a bot uses it ten thousand times: buying all the concert tickets, creating fake accounts to farm referral credit, or booking every appointment slot. Per-endpoint rate limits rarely help, because each request looks normal. Identify the flows that would hurt the business if automated and limit those flows as a whole: per-account and per-payment-instrument purchase caps, device and behaviour signals, queueing for high-demand releases, and delaying rewards until a referred account shows real activity.
Outbound calls: SSRF and unsafe consumption
API7, SSRF. Webhook configuration, "import from URL", link previews and PDF rendering all make your server fetch a URL that a user supplied. The attacker supplies an internal address, such as a cloud metadata endpoint, an admin service on the private network, or localhost, and your server fetches it from inside your network. Checking the URL string is not enough, because hostnames can resolve to private addresses, redirects can bounce to them, and DNS can change between your check and the fetch. Resolve the name, check the address, then connect to the address you checked:
import ipaddress, socket
from urllib.parse import urlsplit
ALLOWED_SCHEMES = {"https"}
ALLOWED_PORTS = {443}
def safe_target(url):
u = urlsplit(url)
if u.scheme not in ALLOWED_SCHEMES or (u.port or 443) not in ALLOWED_PORTS:
raise ValueError("scheme or port not allowed")
infos = socket.getaddrinfo(u.hostname, 443, proto=socket.IPPROTO_TCP)
addrs = {ipaddress.ip_address(i[4][0]) for i in infos}
for a in addrs:
if (a.is_private or a.is_loopback or a.is_link_local or a.is_reserved
or a.is_multicast or a.is_unspecified):
raise ValueError(f"blocked address {a}")
return u.hostname, sorted(addrs, key=str)[0]
# Then connect to the checked IP (sending the original Host header and SNI),
# with redirects disabled or re-checked hop by hop, and a short timeout.Run the fetcher with network egress rules that block private ranges as well, so a bug in the code above is not the only defence. When you can, use an allowlist of destinations instead of a blocklist.
API10, unsafe consumption. Data from a partner API, a payment provider or an enrichment service is still untrusted input. Validate it against a schema, cap its size, set timeouts, never follow its redirects blindly, and never pass its fields into SQL, shell commands or templates without the same escaping you apply to user input. If a vendor is compromised and you trust its data, the attacker reaches you too.
Configuration and inventory
API8, misconfiguration, covers the settings around the code: CORS that reflects any origin with credentials allowed, stack traces in error responses, debug endpoints left enabled, missing TLS on internal hops, permissive HTTP methods, and unpatched frameworks. Treat them as tests: a CI step that sends an Origin: https://evil.example request and fails if that origin is reflected, or sends a malformed body and fails on a stack trace, catches regressions review misses.
API9, improper inventory, is the reason the other nine stay unfixed. Version 1 was replaced by version 2 but still runs, BOLA bug included; a staging host has public DNS; a partner endpoint was never documented. Generate the inventory from what is actually deployed (gateway route tables, OpenAPI specs published from the build, and traffic logs), and compare it with what is documented. Every endpoint needs an owner, a version, an audience and a retirement date.
Testing every risk
Every risk on the list can be tested, and the most valuable test is cheap: the two-user test. Create users A and B in different tenants. For every endpoint that takes an object ID, have A create an object, then try to read, update and delete it as B. Every attempt must fail, and it must fail the same way as a request for an ID that does not exist. Run it in CI against every route in the inventory.
def test_bola_matrix(client, routes, user_a, user_b):
for route in routes.with_object_ids():
obj_id = route.create_as(user_a)
for method in route.methods:
r = client.request(method, route.url(obj_id), auth=user_b.token,
json=route.sample_body(method))
assert r.status_code == 404, (method, route.template, r.status_code)Add one test per risk: unknown fields rejected (API3), an unprivileged user calling admin routes (API5), oversized pages and bodies (API4), internal addresses passed to URL-taking endpoints (API7), and an inventory diff (API9). Scanners help with API8; the rest depends on business rules only your own tests know.
Trade-offs
Central policy against inline checks. A policy engine gives one place to audit decisions, but it adds latency and a dependency that can fail. Checks written into the query are fast and hard to bypass, but they are scattered across the code. Many teams do both: ownership in the query, and richer rules in the engine.
404 against 403. Returning 404 for objects the caller cannot see hides which IDs exist, but it can confuse legitimate users who have lost access. Be consistent, because inconsistency is itself a leak.
Strict schemas against flexibility. Rejecting unknown fields will break clients that send extra data. Log rejections first, then enforce.
Friction against conversion. API6 controls such as CAPTCHAs, queues and purchase caps cost real customers time. Apply them only to the flows that are actually abused, and measure the effect on legitimate users.
What to do next
- Export a list of every route that is actually deployed, from the gateway and from traffic logs, and compare it with your OpenAPI specs (API9).
- Add the two-user BOLA matrix test to CI, generated from that route list (API1).
- Make every update handler use an input schema that forbids unknown fields, and every response use an explicit output schema (API3).
- Add a deny-by-default route policy table and a test that fails when a route has no policy (API5).
- Check token validation: pinned algorithm, issuer, audience and expiry, and lockouts on the login and reset endpoints (API2).
- Clamp page sizes, body sizes and batch lengths in code, and set budgets on paid outbound integrations (API4).
- Find every place your server fetches a user-supplied URL and route it through one resolved-IP-checking fetcher with blocked egress (API7).
- List the three business flows that would hurt most if automated and give each one a flow-level limit (API6).