Back to Blog
Security16 min readJun 2026

OWASP API Security Top 10 (2023): The Risks Your Web Scanner Misses

APIs fail in ways a web vulnerability scanner never sees. This is the API-specific Top 10, what each risk is, the one-line fix, and the code that actually stops it.

API SecurityOWASPBOLAAuthorization
SB

Sri Balaji

Founder

On this page

Why APIs need their own Top 10

Who this is for

Backend and full-stack engineers shipping REST or GraphQL APIs, and anyone who has read [the OWASP Top 10](/blog/the-owasp-top-10-explained) and wondered why their API still got breached. You will leave knowing the ten API-specific risks and the smallest code change that fixes the worst of them.

The classic OWASP Top 10 is about how a browser-rendered web app breaks: injection, XSS, broken access control through the UI. But an API has no UI. The attacker talks straight to your JSON endpoints, sees every parameter, and replays requests at will. The failures shift from 'what can I inject into a page' to 'what object can I reach that is not mine'.

That is why OWASP publishes a separate API Security Top 10. The 2023 edition is dominated not by injection but by authorization, three of the top five entries are some flavor of 'you forgot to check who is asking'. A scanner that crawls HTML will sail right past all of them.

Web Top 10 worries aboutAPI Top 10 worries about
Injection, XSS, CSRF in rendered pagesObject-level and function-level authorization
Session handling in the browserToken validation on every endpoint
One trusted frontendMany untrusted clients hitting raw endpoints
Misconfig of the web serverMisconfig + undocumented 'zombie' API versions
Same spirit, different battlefield.

The one idea behind half the list: authorization

API security is mostly the discipline of checking, on every single request, that the authenticated caller is allowed to touch this exact object and this exact operation.
The whole list in one sentence
A hotel keycard that opens the buildingAuthentication, proving who you are
That same card only opening room 412, your roomAuthorization, what you may access
A card that opens every door because the lock never checks the room numberBOLA, broken object-level authorization
A guest card that also opens the manager's officeBroken function-level authorization
Authentication is the lobby. Authorization is every door inside.

Know the difference cold

If 'authentication vs authorization' is fuzzy, pause and read [authentication vs authorization](/blog/authentication-vs-authorization) first, most of this article is just authorization applied per-object and per-function.

Anatomy of the #1 risk: BOLA

BOLA (Broken Object-Level Authorization) is the most common and most damaging API flaw. The pattern: an endpoint takes an object id from the URL, fetches that object, and returns it, without ever checking that the object belongs to the caller. Change the id, get someone else's data.

id = 1043SELECT by idwrong tenant200 OK
Attacker

Valid token, tenant A

GET /invoices/1043

API gateway

Invoice handler

No owner check

Invoices DB

All tenants

Tenant B data

Leaked in response

A BOLA attack: the attacker is authenticated as tenant A but increments the invoice id to read tenant B's data.

  1. 1

    Log in legitimately

    The attacker creates a normal account and gets a valid token for tenant A. Authentication is fine, that is the trap.

  2. 2

    Find an object id

    They request their own invoice, /invoices/1042, and see a sequential integer id in the URL and body.

  3. 3

    Tamper with the id

    They change 1042 to 1043, 1044, 9999, IDOR-style enumeration straight against the API.

  4. 4

    Harvest

    Every request that returns 200 instead of 403 is another tenant's data. A script walks the whole id space in minutes.

UUIDs are not authorization

Switching to random UUIDs makes enumeration harder, not impossible, ids leak through logs, referrers, and other endpoints. The only real fix is an ownership check on every fetch. Obscurity buys time, not safety.

All ten risks, mapped

Here is the full 2023 list. Read the 'fix' column as a checklist, most are a single guard you either have or you do not.

RiskWhat it isThe fix
API1 Broken Object-Level Auth (BOLA)Caller reaches objects they do not own by changing an idCheck ownership on every object fetch, not just on login
API2 Broken AuthenticationWeak tokens, no expiry, guessable resets, missing rate limits on loginStandard auth (OAuth2/OIDC), short-lived signed tokens, lock out brute force
API3 Broken Object Property-Level AuthMass assignment or over-exposed fields (excessive data exposure)Allowlist writable fields; return only fields the caller may see
API4 Unrestricted Resource ConsumptionNo limits on rate, payload size, or expensive queries -> DoS / huge billsRate limit, cap page size and body size, add timeouts and quotas
API5 Broken Function-Level AuthRegular user calls admin-only operations (e.g. DELETE, /admin/*)Enforce role/permission per route, default-deny
API6 Unrestricted Access to Sensitive Business FlowsAutomation abuses a legit flow (scalping, mass signup, spam)Bot defense, step-up checks, business-rate limits on the flow
API7 Server-Side Request Forgery (SSRF)API fetches a user-supplied URL, attacker pivots to internal servicesValidate + allowlist outbound hosts, block internal IP ranges
API8 Security MisconfigurationDefault creds, verbose errors, missing TLS, permissive CORSHarden defaults, generic errors, scan config in CI
API9 Improper Inventory ManagementForgotten old versions / undocumented 'shadow' endpointsMaintain an API inventory; retire and gate old versions
API10 Unsafe Consumption of APIsBlindly trusting data from third-party / upstream APIsValidate upstream responses; treat partners as untrusted input
OWASP API Security Top 10 (2023).

The vulnerable endpoint

Theory is cheap. Here is a real BOLA bug, an Express handler that authenticates the user but never checks who owns the invoice. It looks fine in code review until you ask the one question: 'whose invoice is this?'

routes/invoices.ts (vulnerable)
typescript
// requireAuth has already verified the JWT and set req.user.
// Looks safe, but it never checks ownership.
router.get('/invoices/:id', requireAuth, async (req, res) => {
  const invoice = await db.invoice.findUnique({
    where: { id: Number(req.params.id) },
  });

  if (!invoice) return res.status(404).json({ error: 'Not found' });

  // BOLA: any logged-in user can read ANY invoice by id.
  return res.json(invoice);
});

Authenticated is not authorized

requireAuth proves the caller is *a* user. It says nothing about whether this invoice is *their* invoice. That gap is API1, the single most exploited API flaw.

The fixed endpoint

The fix is one clause: scope the query to the caller, or compare the owner after fetching. Scoping in the query is best, it is impossible to forget the check because there is no row to leak.

routes/invoices.ts (fixed)
typescript
router.get('/invoices/:id', requireAuth, async (req, res) => {
  // Ownership is part of the lookup, not an afterthought.
  const invoice = await db.invoice.findFirst({
    where: {
      id: Number(req.params.id),
      ownerId: req.user.id, // <-- the entire fix
    },
  });

  // Return 404 (not 403) so we do not confirm the id exists.
  if (!invoice) return res.status(404).json({ error: 'Not found' });

  return res.json(invoice);
});

Now patch API3 at the same time: never trust the request body to decide which fields get written, and never spread the whole object back. Allowlist both directions.

routes/invoices.ts (property-level auth)
typescript
// API3 fix: explicit allowlists, no mass assignment, no over-exposure.
router.patch('/invoices/:id', requireAuth, async (req, res) => {
  // Only these fields may be written by a normal user.
  const { note, dueDate } = req.body; // ignore status, ownerId, isPaid...

  const updated = await db.invoice.updateMany({
    where: { id: Number(req.params.id), ownerId: req.user.id },
    data: { note, dueDate },
  });

  if (updated.count === 0) return res.status(404).json({ error: 'Not found' });

  // Return only safe fields, never the raw row.
  const view = await db.invoice.findFirst({
    where: { id: Number(req.params.id), ownerId: req.user.id },
    select: { id: true, note: true, dueDate: true, total: true },
  });
  return res.json(view);
});

Make the safe path the default

Wrap data access in a layer that *requires* a tenant/owner scope (a repository or row-level security in the DB). If a developer cannot write a query without scoping it, BOLA stops being a thing you can forget.

Function-level auth and rate limits

API5 is BOLA's sibling for *operations*: a normal user calling an admin route. The fix is the same shape, check a permission, default-deny, but on the verb/route instead of the object.

middleware/authorize.ts
typescript
// Default-deny role guard. Apply per route.
export const requireRole = (role: string) =>
  (req: Request, res: Response, next: NextFunction) => {
    if (!req.user?.roles?.includes(role)) {
      return res.status(403).json({ error: 'Forbidden' });
    }
    next();
  };

// API5 fix: admin-only deletion is gated by an explicit role.
router.delete('/invoices/:id', requireAuth, requireRole('admin'),
  deleteInvoiceHandler);

API4 (resource consumption) is what turns a slow endpoint into an outage or a five-figure cloud bill. Put limits in front of everything: requests per minute, page size, and body size.

app.ts (limits)
typescript
import rateLimit from 'express-rate-limit';

// API4: cap how fast and how big requests can be.
app.use(rateLimit({ windowMs: 60_000, max: 100 })); // 100 req/min/IP
app.use(express.json({ limit: '100kb' }));           // reject huge bodies

// Cap pagination so nobody asks for 1,000,000 rows.
function pageSize(raw: unknown) {
  return Math.min(Number(raw) || 25, 100);
}

SSRF in one line

For API7, if your service fetches a URL the user supplied, allowlist the destination host and block private ranges (127.0.0.0/8, 169.254.169.254, 10.0.0.0/8). Otherwise an attacker turns your server into a proxy to your own cloud metadata endpoint.

Common mistakes that cost hours

  1. Checking auth in the gateway only. The gateway proves identity; it almost never knows object ownership. Authorization belongs next to the data.
  2. Trusting the client to hide fields. If the mobile app does not show isPaid, the API still accepts it. Allowlist on the server or it is mass assignment.
  3. Returning 403 for objects that are not yours. A 403 confirms the id exists. Return 404 so attackers cannot map your id space.
  4. Relying on UUIDs as a security control. They slow enumeration but never replace an ownership check.
  5. Leaving old API versions running. /v1 with no auth fixes is API9, a back door you forgot you built. Inventory and retire them.
  6. Verbose error messages in production. Stack traces and SQL in responses (API8) hand the attacker your schema for free.
  7. No rate limit on login or expensive search. That is API2 and API4 working together to enable credential stuffing and DoS.
  8. Trusting upstream APIs. A partner's compromised response is your injection vector (API10). Validate it like any user input.

Takeaways

The whole article in seven lines

  • The API Top 10 is a different list from the web Top 10, authorization dominates, not injection.
  • BOLA (API1) is #1: check object ownership on *every* fetch, not just at login.
  • Scope queries to the caller (ownerId in the WHERE clause) so the check cannot be forgotten.
  • Allowlist writable and readable fields to kill mass assignment and data over-exposure (API3).
  • Default-deny on routes for function-level auth (API5); gate admin operations by role.
  • Rate-limit, cap payloads, and cap pagination to stop resource-consumption DoS (API4).
  • Retire old versions, harden config, validate upstream and user-supplied URLs (API7-API10).

Where to go next

You now have the map and the two fixes that matter most. Build the muscle memory before you ship.

Audit your own API today

Pick one endpoint that takes an id. Log in as user A, grab user B's id, and call it. If you get a 200, you just found your first BOLA, go add the ownerId clause.

Want to go deeper?

This article covers concepts taught hands-on in the Cloud Engineer and DevOps career paths, with real terminal labs, production scenarios, and structured lessons.