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.

IDRiskIn one sentenceWhere the fix lives
API1Broken Object Level AuthorizationA caller reads or changes an object by guessing or swapping its IDEvery data access, scoped to the caller
API2Broken AuthenticationWeak login, token or credential handling lets someone become another userIdentity provider and token validation
API3Broken Object Property Level AuthorizationResponses expose fields, or requests set fields, the caller should not touchExplicit input and output schemas
API4Unrestricted Resource ConsumptionNo limits on size, rate or cost lets a caller exhaust resources or your billGateway limits plus per-endpoint caps
API5Broken Function Level AuthorizationA regular user can call admin or other privileged operationsDeny-by-default route policy
API6Unrestricted Access to Sensitive Business FlowsLegitimate endpoints automated at scale: scalping, spam, referral abuseFlow-level controls and abuse signals
API7Server Side Request ForgeryThe API fetches a URL supplied by the caller and reaches internal targetsOutbound allowlist and IP checks
API8Security MisconfigurationPermissive CORS, verbose errors, missing headers, unpatched stacksHardened defaults, checked in CI
API9Improper Inventory ManagementOld versions, debug and shadow endpoints nobody tracksAn API inventory tied to deployment
API10Unsafe Consumption of APIsTrusting data from third-party APIs more than user inputValidate and bound everything inbound
Clientapp, script, botAPI gatewayauthn (API2), limits (API4)Route policyfunction authz (API5)Handlerobject authz (API1), schemas (API3)Flow guardbusiness flows (API6)Outbound fetchSSRF (API7)Third-party APIconsumption (API10)Databasequeries scoped to callerAround everything: inventory of every version and endpoint (API9) and hardened configuration (API8)
Where each control sits in a typical request path. Gateways help with API2 and API4; API1, API3 and API5 must be enforced in your own code.

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 row

Putting 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 (limit clamped 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

  1. 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).
  2. Add the two-user BOLA matrix test to CI, generated from that route list (API1).
  3. Make every update handler use an input schema that forbids unknown fields, and every response use an explicit output schema (API3).
  4. Add a deny-by-default route policy table and a test that fails when a route has no policy (API5).
  5. Check token validation: pinned algorithm, issuer, audience and expiry, and lockouts on the login and reset endpoints (API2).
  6. Clamp page sizes, body sizes and batch lengths in code, and set budgets on paid outbound integrations (API4).
  7. Find every place your server fetches a user-supplied URL and route it through one resolved-IP-checking fetcher with blocked egress (API7).
  8. List the three business flows that would hurt most if automated and give each one a flow-level limit (API6).
Key takeaway: The OWASP API Security Top 10 2023 is still the current API list and is mostly about authorization: scope every object lookup to the caller, use explicit input and output schemas, and deny routes that have no declared policy. Add token validation, limits on size, rate, cost and business flows, a resolved-IP check on every outbound fetch, and an inventory generated from what is actually deployed. Then prove it with a two-user test that runs against every route.