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.
| ID | Risk | In one sentence |
|---|---|---|
| API1 | Broken Object Level Authorization | Change the id, get someone else's object. |
| API2 | Broken Authentication | Weak tokens, no rate limits, guessable credentials. |
| API3 | Broken Object Property Level Authorization | Read or write fields you should not — over-exposure plus mass assignment. |
| API4 | Unrestricted Resource Consumption | No caps, so one caller costs you money or uptime. |
| API5 | Broken Function Level Authorization | Call the admin endpoint as a normal user. |
| API6 | Unrestricted Access to Sensitive Business Flows | Automate a flow that assumed a human — scalping, mass signup. |
| API7 | Server Side Request Forgery | The API fetches a URL the attacker chose. |
| API8 | Security Misconfiguration | Defaults, debug modes, permissive CORS, missing headers. |
| API9 | Improper Inventory Management | Shadow and zombie APIs nobody is watching. |
| API10 | Unsafe Consumption of Third-Party APIs | Trusting 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
# 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.
// 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 fieldAPI6 — 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.
// 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.
// 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