What API Security Actually Means
Why APIs fail differently from web pages, and the four properties every endpoint has to defend.
A web page renders what the server decides to show. An API hands over structured data and trusts the caller to behave. Remove the UI and every assumption baked into it disappears: the dropdown that only listed your own projects, the button that was hidden for non-admins, the field that was read-only.
Why APIs fail differently
- No UI constraints. Anything the UI prevented,
curlpermits. - Object identifiers are exposed.
/api/invoices/1043invites/api/invoices/1044. - Responses over-share. Serialising a model returns every column, including the ones the screen never displayed.
- Automation is trivial. A scraper enumerates a million ids overnight; nobody does that by hand through a browser.
- The attack surface is documented. Your OpenAPI spec is a map, and you published it.
The four properties
Every endpoint has to hold four things at once. Each has its own failure mode.
| Property | Question | Fails as |
|---|---|---|
| Authentication | Who is calling? | anyone can act as anyone |
| Authorization | May they do this, to this object? | BOLA — the most common API breach |
| Integrity | Is the input what we think it is? | injection, mass assignment |
| Availability | Can one caller ruin it for everyone? | resource exhaustion, scraping |
Where the breaches actually come from
Not from broken cryptography. From authorization checks that were never written. An endpoint authenticates perfectly, then returns object 1044 to the person who owns object 1043. That single pattern accounts for more real-world API incidents than every other category combined — which is why the OWASP API Top 10 opens with it.
The mental model
Assume the caller is hostile, has read your documentation, has a valid account, and is patient. Every question then becomes concrete: what does this caller get if they change this value?
Example
# The UI would never let a user do any of this. The API has no UI.
# 1. Another user's object — is it scoped to the caller?
curl https://dfg.com/api/invoices/1044 -H "Authorization: Bearer $MY_TOKEN"
# 2. A field the form marked read-only — is it in the update allowlist?
curl -X PATCH https://dfg.com/api/users/me \
-H "Authorization: Bearer $MY_TOKEN" \
-d '{"role":"admin","accountBalance":999999}'
# 3. A page size the dropdown capped at 50 — is it bounded server-side?
curl "https://dfg.com/api/users?limit=100000" -H "Authorization: Bearer $MY_TOKEN"
# 4. A method the UI never issues — does the route exist?
curl -X DELETE https://dfg.com/api/projects/7 -H "Authorization: Bearer $MY_TOKEN"
# Four commands, four whole vulnerability classes. This is the entire job.When to use it
- A mobile app's API is discovered to return every user column because the team serialised the model directly, exposing password hashes and internal flags.
- A competitor scrapes a full product catalogue overnight through an endpoint that had no pagination cap, because the UI only ever requested 20 items.
- A logged-in customer reads another customer's invoices by incrementing an id, on an endpoint whose authentication was flawless.
More examples
The same endpoint, from the UI and from curl
Three separate defences in one handler: a field allowlist, an ownership-scoped update, and an explicit response shape. Each closes a different OWASP category.
// What the frontend sends — constrained by the UI
await fetch('/api/projects/42', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Renamed project' }), // one editable field
});
// What an attacker sends — constrained by nothing
// PATCH /api/projects/42
// {
// "name": "Renamed project",
// "ownerId": 1, ← reassign it to themselves
// "tier": "enterprise", ← upgrade the plan
// "isPublic": true, ← expose it
// "deletedAt": null, ← undelete something
// "__proto__": { "isAdmin": true } ← prototype pollution, for good measure
// }
// ❌ The handler that trusts the shape the UI happens to send
app.patch('/api/projects/:id', auth, async (req, res) => {
const project = await db.projects.update(req.params.id, req.body);
res.json(project);
});
// ✅ Explicit allowlist, and ownership inside the query
const UPDATABLE = ['name', 'description'];
app.patch('/api/projects/:id', auth, async (req, res) => {
const patch = Object.fromEntries(
Object.entries(req.body ?? {}).filter(([k]) => UPDATABLE.includes(k))
);
if (!Object.keys(patch).length) {
return res.status(400).json({ error: 'no_updatable_fields' });
}
const updated = await db.projects.updateWhere(
{ id: req.params.id, ownerId: req.user.id }, // authorization IS the query
patch,
);
if (!updated) return res.status(404).json({ error: 'not_found' });
res.json(publicProject(updated)); // and a shaped response
});How much your own documentation gives away
Treating your own documentation as reconnaissance material is a fast, honest way to produce a prioritised list of what to review first.
# Your OpenAPI spec is a complete map of the attack surface. That is fine —
# obscurity is not a control — but know what it tells someone.
curl -s https://dfg.com/openapi.json | jq -r '
.paths | to_entries[] |
.key as $path | .value | to_entries[] |
"\(.key | ascii_upcase) \($path)"' | head -20
# GET /api/users/{id}
# PATCH /api/users/{id}
# DELETE /api/users/{id} ← is this admin-only? enforced where?
# GET /api/invoices/{id} ← sequential ids? scoped to the caller?
# POST /api/admin/impersonate ← who can reach this?
# GET /api/internal/metrics ← why is 'internal' on a public spec?
# The questions an attacker asks, in order:
# 1. which endpoints take an object id? → BOLA candidates
# 2. which are admin/internal by name? → BFLA candidates
# 3. which accept a body? → mass assignment, injection
# 4. which return collections? → excessive exposure, DoS
# 5. which touch another system? → SSRF
# Run this against your own spec and answer all five. That is a threat model.The five-minute triage on any endpoint
A repeatable checklist beats intuition in review, because it catches the endpoint you would have skimmed past on a Friday afternoon.
For each endpoint, answer these. An unanswered one is a finding.
1. AUTHENTICATION
Who can reach it unauthenticated?
Is the identity taken from a verified credential and nothing else?
2. AUTHORIZATION — OBJECT
It takes an id. Where is the ownership/tenant check?
Is it INSIDE the query, or a check after the fetch?
Does a foreign object return 404 rather than 403?
3. AUTHORIZATION — FUNCTION
Is this action restricted by role or scope?
Is that enforced on the ROUTE, or assumed because the UI hides the button?
4. INPUT
Is the body validated against a schema, with unknown fields REJECTED?
Which fields are writable? Is that an allowlist or a denylist?
5. OUTPUT
Is the response shaped explicitly, or is a model serialised?
What is in it that the screen never shows?
6. RESOURCE USE
Is there a cap on limit / depth / size / duration?
What does this cost if called 10,000 times a minute?
7. OUTBOUND
Does it fetch a URL, render a template, or run a query from user input?
# Seven questions, five minutes, and it catches the majority of real findings.
Discussion