The OWASP API Security Top 10

The industry's shared vocabulary for API risk, what each item means in code, and where the rest of this course covers it.

OWASP maintains a list specifically for APIs, separate from the web application Top 10, because APIs fail differently. It is the vocabulary interviewers and auditors use, so knowing the identifiers is worth the ten minutes.

IDRiskIn one sentence
API1Broken Object Level AuthorizationChange the id, get someone else's object.
API2Broken AuthenticationWeak tokens, no rate limits, guessable credentials.
API3Broken Object Property Level AuthorizationRead or write fields you should not — over-exposure plus mass assignment.
API4Unrestricted Resource ConsumptionNo caps, so one caller costs you money or uptime.
API5Broken Function Level AuthorizationCall the admin endpoint as a normal user.
API6Unrestricted Access to Sensitive Business FlowsAutomate a flow that assumed a human — scalping, mass signup.
API7Server Side Request ForgeryThe API fetches a URL the attacker chose.
API8Security MisconfigurationDefaults, debug modes, permissive CORS, missing headers.
API9Improper Inventory ManagementShadow and zombie APIs nobody is watching.
API10Unsafe Consumption of Third-Party APIsTrusting someone else's response as if it were safe.

Notice what is missing

Injection has no dedicated slot. That is not because it stopped mattering — it is because authorization failures overtook it as the dominant cause of real API breaches. Three of the ten (API1, API3, API5) are authorization, and a fourth (API6) is authorization at the business-logic level.

The two most valuable to internalise

API1 is the single most common finding in the wild. It is trivially exploitable, needs no special tools, and is invisible to a scanner because only you know that invoice 1044 does not belong to that user.

API6 is the one people miss entirely, because nothing is technically broken. Every request is authenticated, authorized and valid — there are simply ten thousand of them, from a script, buying every unit of stock in four seconds.

Example

Example · bash
# One endpoint, five categories, depending on what you change.

# API1 — object level: whose invoice?
curl /api/invoices/1044        -H "$AUTH"     # not mine → must be 404

# API3 — property level: what came back, and what can I write?
curl /api/invoices/1043        -H "$AUTH" | jq 'keys'
# ["id","total","internalRiskScore","customerSsn"]   ← over-exposure
curl -X PATCH /api/invoices/1043 -H "$AUTH" -d '{"status":"paid"}'  ← mass assignment

# API4 — resource consumption: is the page size bounded?
curl "/api/invoices?limit=1000000" -H "$AUTH"

# API5 — function level: is the admin route enforced?
curl -X DELETE /api/admin/invoices/1043 -H "$AUTH"

# API6 — business flow: nothing is 'broken', and yet
for i in $(seq 1 10000); do curl -X POST /api/checkout -H "$AUTH" & done

# Five commands. Run them against your own API before someone else does.

When to use it

  • A security review is scoped by walking the Top 10 against each endpoint, giving complete coverage rather than depth on whatever the reviewer knows best.
  • An auditor asks for evidence per OWASP API category, and the team maps existing tests to each identifier in an afternoon.
  • A ticket-selling API adds proof-of-work and per-account purchase caps after recognising API6 as its real risk, not injection.

More examples

The three authorization categories, distinguished

One endpoint can hold all three at once, which is why reviewing 'is it authorized?' as a single question misses two of them.

Example · javascript
// These get confused constantly. Interviewers ask for the difference.

// ── API1: OBJECT level — the right FUNCTION, the wrong OBJECT ──────────
app.get('/api/invoices/:id', auth, async (req, res) => {
  const invoice = await db.invoices.findById(req.params.id);   // ❌ any id
  res.json(invoice);
});
// Alice may read invoices. This one is Bob's.
// FIX: scope the query.
const invoice = await db.invoices.findOne({
  id: req.params.id, userId: req.user.id,
});
if (!invoice) return res.status(404).json({ error: 'not_found' });

// ── API5: FUNCTION level — the wrong FUNCTION entirely ────────────────
app.delete('/api/admin/users/:id', auth, deleteUser);   // ❌ no role check
// Alice is not an admin. She can still call the admin route.
// FIX: enforce on the route.
app.delete('/api/admin/users/:id', auth, requireRole('admin'), deleteUser);

// ── API3: PROPERTY level — the right object, the wrong FIELDS ─────────
app.patch('/api/users/me', auth, async (req, res) => {
  await db.users.update(req.user.id, req.body);          // ❌ any field
  res.json(await db.users.findById(req.user.id));        // ❌ every column
});
// Alice may edit her own profile. She may not set role:'admin' on it,
// and the response should not include her password hash.
// FIX: allowlist in, shape out.
const WRITABLE = ['name', 'bio', 'avatarUrl'];
const patch = pick(req.body, WRITABLE);
await db.users.update(req.user.id, patch);
res.json(pick(await db.users.findById(req.user.id), ['id', 'email', 'name', 'bio']));

// Summary you can say out loud:
//   API1 = wrong object     API5 = wrong function     API3 = wrong field

API6 — when nothing is broken and everything is wrong

Naming API6 in an interview and explaining that the defence is economic rather than technical is a strong differentiator — most candidates never mention it.

Example · javascript
// Every request below is authenticated, authorized, validated and correct.
// The business still loses.

// The flow: buy a limited-edition item.
app.post('/api/checkout', auth, validate(checkoutSchema), async (req, res) => {
  const item = await db.items.findById(req.body.itemId);
  if (item.stock < 1) return res.status(409).json({ error: 'out_of_stock' });
  await db.orders.create({ userId: req.user.id, itemId: item.id });
  await db.items.decrement(item.id, 'stock');
  res.status(201).json({ ok: true });
});

// A bot with 500 legitimate accounts empties the stock in four seconds.
// No vulnerability scanner will report anything, because nothing is broken.

// Defences are business-shaped, not code-shaped:

// 1. Per-account and per-payment-instrument limits
const bought = await db.orders.countFor(req.user.id, item.id, { since: '24h' });
if (bought >= item.maxPerCustomer) {
  return res.status(429).json({ error: 'purchase_limit_reached' });
}

// 2. Account age and verification gates on high-demand items
if (item.highDemand && accountAgeDays(req.user) < 30) {
  return res.status(403).json({ error: 'account_too_new' });
}

// 3. Cost asymmetry — cheap for a human, expensive for 10,000 bots
if (item.highDemand && !(await verifyProofOfWork(req.body.pow, req.user.id))) {
  return res.status(400).json({ error: 'proof_of_work_required' });
}

// 4. Detect automation rather than block a request
if (await looksAutomated(req)) {          // timing regularity, device signals
  await queueForReview(req.user.id);       // do not block; observe
}

// The senior insight: API6 is not fixed in the request handler. It is fixed by
// changing the economics of the flow.

Mapping the Top 10 to your own test suite

Driving the API5 test from the generated route inventory is the detail that keeps coverage complete as the codebase grows.

Example · javascript
// Auditors ask for evidence per category. Tag the tests and it is a grep.

describe('OWASP API Top 10', () => {
  describe('API1: Broken Object Level Authorization', () => {
    it("returns 404 for another user's invoice", async () => {
      const res = await get(`/api/invoices/${bobInvoice.id}`, aliceToken);
      expect(res.status).toBe(404);
    });
    it('scopes list endpoints to the caller', async () => {
      const res = await get('/api/invoices', aliceToken);
      expect(res.body.every((i) => i.userId === alice.id)).toBe(true);
    });
  });

  describe('API3: Broken Object Property Level Authorization', () => {
    it('ignores non-writable fields', async () => {
      await patch('/api/users/me', { role: 'admin' }, aliceToken);
      expect((await db.users.findById(alice.id)).role).toBe('user');
    });
    it('never returns internal columns', async () => {
      const res = await get('/api/users/me', aliceToken);
      for (const f of ['passwordHash', 'totpSecret', 'internalRiskScore']) {
        expect(res.body).not.toHaveProperty(f);
      }
    });
  });

  describe('API4: Unrestricted Resource Consumption', () => {
    it('caps page size', async () => {
      const res = await get('/api/invoices?limit=1000000', aliceToken);
      expect(res.body.length).toBeLessThanOrEqual(100);
    });
  });

  describe('API5: Broken Function Level Authorization', () => {
    it('rejects admin routes for normal users', async () => {
      for (const route of ADMIN_ROUTES) {
        expect((await call(route, aliceToken)).status).toBe(403);
      }
    });
  });

  describe('API7: SSRF', () => {
    it('refuses internal and metadata addresses', async () => {
      for (const url of ['http://169.254.169.254/', 'http://localhost:6379/',
                         'http://10.0.0.1/', 'file:///etc/passwd']) {
        expect((await post('/api/import', { url }, aliceToken)).status).toBe(400);
      }
    });
  });
});

// ADMIN_ROUTES generated from the route inventory, so a NEW admin route is
// covered the moment it is added rather than when someone remembers.

Discussion

  • Be the first to comment on this lesson.