Almost everything that goes seriously wrong in an API comes down to one question the code failed to ask: is this particular caller allowed to touch this particular thing? Injection and misconfiguration still appear, but the findings that turn into real incidents — the ones where a tester walks away with the whole customer table — are authorisation failures, over-sharing, and endpoints that were never meant to be called by a stranger.
The OWASP API Security Top 10 is a good map of that terrain. What follows is organised around it but written from the other side of an API testing engagement: the classes we actually spend our time on, in the order they tend to produce findings, with the shapes they take on the wire. All examples below are generic illustrations, not drawn from any client system.
The assumption underneath most of it
Web application security had a helpful accident: developers could see the attacker's tooling. A browser has a URL bar, view-source, and devtools, so it was always obvious that the client was under someone else's control.
APIs feel different, and that feeling is the root cause of much of this list. The caller is a machine — usually your mobile app or your SPA — so it is easy to reason as if the client were part of the trusted system. From there it follows naturally that the mobile app will only request the user's own records, that the admin button is hidden so the admin endpoint will not be called, that the frontend will not display fields it was told to hide, and that nobody will send a field the form does not contain.
Every one of those is a security control implemented on the attacker's computer. In testing, we treat the documented client as one possible caller among many, and most findings fall directly out of that shift.
Broken object-level authorisation (BOLA)
The most common serious finding, and the most damaging.
The API authenticates the caller correctly, then fetches the requested object by identifier without checking that the object belongs to the caller.
GET /api/v1/orders/78412 HTTP/1.1
Authorization: Bearer <token for user 5510>
HTTP/1.1 200 OK
{ "id": 78412, "customer_id": 9903, "email": "someone@example.com",
"shipping_address": "...", "total": 249.00 }
Order 78412 belongs to customer 9903; the token belongs to 5510. The authentication layer did its job — the token was valid — and then nothing asked the second question. In code it is usually a single missing clause:
// vulnerable: scoped only by the id the caller supplied
const order = await Order.findById(req.params.id);
// correct: scoped by the identity the server derived from the token
const order = await Order.findOne({
_id: req.params.id,
customerId: req.user.customerId, // never req.body / req.query / a header
});
Why automation misses it: every response is a well-formed 200 OK. There is no
error, no stack trace, no anomaly. Whether the response is a breach depends
entirely on knowing who owns object 78412, which is a fact about your data
model. This is why
authorisation testing is a manual discipline
— the tester holds two accounts and systematically tries each one's identifiers
against the other's session.
A note on UUIDs: replacing sequential integers with UUIDs is worth doing, but it is a mitigation for enumeration, not a fix for the authorisation gap. Object identifiers leak constantly — in shared links, exports, webhooks, notification emails, and other API responses. Treat unguessability as friction and the ownership check as the control.
Broken function-level authorisation
The same failure moved up a level: the check is on which operation rather than which object. Administrative or privileged endpoints are protected only by not being advertised.
Typical patterns we find:
- The UI hides an admin panel from non-admin users, but
POST /api/v1/admin/usersperforms no role check server-side. - Read is protected and write is not, because the guard was applied to
GETand forgotten onPUTandDELETE. - Route-level middleware protects
/api/v1/admin/*but a newer endpoint was registered under/api/v2/internal/outside the pattern.
DELETE /api/v1/admin/users/331 HTTP/1.1
Authorization: Bearer <token for an ordinary user>
HTTP/1.1 204 No Content
Two things reliably surface these: enumerating verbs against every discovered path, and reading the client bundle. A JavaScript bundle usually contains the full route table including endpoints the current user's role never renders, which makes "undocumented" a very thin defence.
Excessive data exposure
The endpoint returns the entire database object and relies on the client to
render only some of it. The hidden fields are one curl away.
GET /api/v1/users/5510/profile HTTP/1.1
HTTP/1.1 200 OK
{
"id": 5510,
"display_name": "A. Example",
"email": "a.example@example.com",
"phone": "+44...",
"password_reset_token": "9f2c1b...",
"internal_risk_score": 82,
"is_flagged_for_review": true,
"stripe_customer_id": "cus_..."
}
The profile page shows the display name and nothing else. Everything below it is
shipped to anyone who calls the endpoint. This typically arrives via a generic
serializer — return jsonify(user.to_dict()) or an ORM object passed straight
into the response — and it gets worse over time, because every column added
later is silently published.
The fix is structural rather than a patch: define explicit response schemas per endpoint and allow-list the fields, so new columns are private by default. It is also worth checking nested and embedded objects, where the discipline usually breaks first — a well-filtered order object often contains a fully populated customer.
Mass assignment
The mirror image. Instead of the API returning too much, it accepts too much, binding client-supplied JSON straight onto a model.
The registration form sends two fields. The tester sends four:
PATCH /api/v1/users/5510 HTTP/1.1
Content-Type: application/json
{ "display_name": "A. Example",
"email": "a.example@example.com",
"role": "admin",
"email_verified": true,
"account_balance": 100000 }
If the handler does user.update(req.body) and saves, privilege escalation is a
single request. Field names are rarely secret — they appear in GET responses
for the same resource, in API documentation, in the client bundle, and in error
messages.
The defence is an explicit input allow-list per endpoint, enforced server-side. Deny-lists fail because the next sensitive field added to the model will not be on the list.
Missing rate limiting and enumeration
Rate limiting is filed as an availability control and then breaks confidentiality. Its absence turns single-request weaknesses into bulk data extraction:
- A BOLA finding on
/api/v1/orders/{id}is one leaked record without rate limiting and the entire order history with it. - Credential stuffing against
/api/v1/auth/login. - One-time codes: a six-digit code with unlimited attempts is not a second factor. Rate limit and lock on the account, not just the source IP.
- Any endpoint that behaves differently for existing and non-existing values becomes a user enumeration oracle.
That last one deserves care, because the leak is often subtler than a different error message:
POST /api/v1/auth/reset HTTP/1.1
{ "email": "known@example.com" } → 200, response in ~410ms
POST /api/v1/auth/reset HTTP/1.1
{ "email": "unknown@example.com" } → 200, response in ~35ms
Identical status, identical body — but the known address triggered a password hash computation and an email send. The timing difference is a reliable oracle. Consistent responses need to be consistent in status, body, and rough timing.
How these compare in practice
| Failure mode | How it is found | Typical blast radius | Automation catches it? |
| ----------------------- | -------------------------------------------------- | --------------------------------------- | --------------------------- |
| BOLA | Cross-account object access with two test accounts | Full dataset of all tenants | No — responses look valid |
| Function-level auth | Verb and route enumeration, client bundle review | Privileged actions, admin takeover | Rarely |
| Excessive data exposure | Reading raw responses, not the UI | Secrets, tokens, internal fields | Partially, with tuned rules |
| Mass assignment | Adding fields observed in GET to PATCH/POST | Privilege escalation | No |
| No rate limiting | Repeat request timing and volume | Turns single leaks into bulk extraction | Sometimes |
| Enumeration | Differential status, body, or timing | Valid account lists for later attacks | Sometimes |
The pattern in the right-hand column is the point. The classes with the largest blast radius are precisely the ones automated scanning cannot resolve, because judging them requires knowing who is allowed to do what in your application.
GraphQL shifts these problems; it does not remove them
Teams sometimes assume a single typed schema tidies this up. It relocates the work.
What genuinely improves. Clients request specific fields, so accidental over-fetching of an entire object is less automatic — provided your resolvers do not fetch the whole record and filter afterwards. Strong typing rejects some malformed input at the edge.
What gets harder.
- Authorisation must live in resolvers. There is one endpoint, so route-level middleware cannot express object-level rules. Every resolver returning sensitive data needs its own ownership check, and a field reachable through several query paths must be guarded on all of them.
- Nested traversal creates new paths to old data. A user may be forbidden from
querying
customer(id:)directly, yet reach the same object viaorder(id:) { customer { email } }because the nested resolver was written by someone who assumed the parent had already authorised it.
query {
order(id: "78412") {
id
customer {
id
email
phone
internalNotes
}
}
}
- Introspection publishes the attack surface. Where it is enabled in production, it hands over every type, field, and mutation — including the internal ones. Convenient in development, generous in production.
- Query complexity replaces rate limiting. Counting requests is meaningless when one deeply nested or aliased query can force enormous work server-side. Request throttling at the edge is not enough on its own: you need depth limits, complexity scoring, and caps on aliased batching.
- Batching bypasses naive throttles. A single HTTP request containing many aliased mutations can retry a one-time code hundreds of times while your per-request counter increments once.
The underlying lesson is the same as REST: authorisation belongs beside the data access, not at the transport boundary.
Where this lands
If your API predates your current permissions model, has grown a v2 alongside a v1, or serves both a first-party app and external integrators, authorisation boundaries are the first place we would look — and they are the hardest part to gain confidence in without someone holding two accounts and systematically testing one against the other. If you are trying to decide whether your APIs warrant a dedicated test or fit inside a broader application engagement, that is a scoping conversation we are happy to have.