# How to review an API against the OWASP API Security Top 10

> Review a running API against the ten risks of the 2023 edition, recording a replayable request and an owner for every risk, and rank the gaps you find.

Source: https://voxgig.com/howto/review-an-api-against-owasp-api-top-ten

- Audience: api-producer
- Level: intermediate
- Verified: 2026-09-24
- Published: 2026-09-24

## Short answer

Enumerate the routes from the router before you open the OpenAPI document, because the review is of the service, not its description. Then take the ten risks in order and record, for each one, the endpoints, a request someone else can replay, what it answered, and an owner. A risk with no gap gets the request that looked and found none. Rank the gaps by severity.

---
## You will need

A running API you own, in an environment where a failed authorization check leaks nothing real.
Two accounts in it with data of their own, because the authorization rows need a second tenant to
read as. The router's route table, or a day of access logs. Node 22 or later for the sample, which
reviews a small invoicing service built with six gaps so the probes have something to find.
Verified 2026-09-24 against Node 22.22.2. The risks are the ten in the
[2023 edition](https://api-security.owasp.org/editions/2023/en/0x11-t10/), which calls itself an
awareness document and not a standard.[^2]

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [A manual review](https://api-security.owasp.org/editions/2023/en/0x11-t10/) | The authorization rows, which need someone who knows which user may see which row | A day or two of a person who knows the data model, and the discipline of recording every request | You have no such person and need a first pass this week |
| [ZAP baseline scan](https://www.zaproxy.org/docs/docker/baseline-scan/) | Every pull request, against staging, for what a passive scan can see | Headers, cookies, and error pages only: it sends no attack and cannot know that one user is not another | The question is who may read what, which no passive scan answers |
| [42Crunch audit](https://docs.42crunch.com/latest/content/concepts/api_contract_security_audit.htm) | An OpenAPI document that is the contract, checked for missing security schemes and loose data definitions | It reads the document rather than the service, so a route the document omits is invisible to it | The document and the router have drifted, which the first step below measures |
| [A penetration test](https://csrc.nist.gov/pubs/sp/800/115/final) | A release, an acquisition, or a customer who asks for the report by name | Money, a scope fixed in advance, and a report that starts aging the day the next endpoint ships | You have not done the review on this page, so paid days go on findings you could have listed |

The tools find the shallow half. A baseline scan of headers and a spec audit of the document
both run every day at no cost. Neither can tell that one customer should not see
the invoices of another, because that is a fact about your data model and not about HTTP. The
authorization rows, which the 2023 list puts first, third, and fifth, need a person, and a
penetration test is a person you pay by the day.

## Enumerate the routes before you open the document

The pitfall that empties a review is doing it against the OpenAPI document. The document is what
the team meant to ship. The router is what is running, and the difference between the two is
[API9, improper inventory management](https://api-security.owasp.org/editions/2023/en/0xa9-improper-inventory-management/),
before you have sent a request. Every router keeps a table. Fastify prints its own with
[`printRoutes()`](https://fastify.dev/docs/latest/Reference/Server/), and a day of access logs is
the same table as the outside world sees it. The sample keeps its routes as data, so the
comparison is a set difference.

```js title="inventory.mjs"
// The keys of a path item that are operations. The others, `parameters` and `summary` among
// them, describe the path itself and are not routes.
const METHODS = new Set(['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'])

export function inventory(routes, openapi) {
  const documented = new Set()
  for (const [path, ops] of Object.entries(openapi.paths ?? {})) {
    for (const method of Object.keys(ops)) if (METHODS.has(method)) documented.add(`${method.toUpperCase()} ${path}`)
  }
  const served = new Set(routes.map((r) => `${r.method} ${r.path}`))
  return {
    served: [...served],
    documented: [...documented],
    undocumented: [...served].filter((r) => !documented.has(r)),
    unserved: [...documented].filter((r) => !served.has(r)),
  }
}
```

Both directions matter. An undocumented route is one no spec-driven tool will ever test. A
documented path with no route is a document nobody has read for a while, which is its own
finding about how the next review will go.

## Record a request for every risk, gap or not

Take the ten risks in the order the document lists them, from
[broken object level authorization](https://api-security.owasp.org/editions/2023/en/0xa1-broken-object-level-authorization/)
to [unsafe consumption of APIs](https://api-security.owasp.org/editions/2023/en/0xaa-unsafe-consumption-of-apis/).
For each one, send the request that would show the gap, and keep the request whatever it
answers. The sample records each probe as the `curl` line that replays it.

```js title="review.mjs"
async function probe(base, { method = 'GET', path, token, expected }) {
  const headers = token ? { authorization: `Bearer ${token}` } : {}
  const res = await fetch(base + path, { method, headers })
  const body = await res.text()
  const auth = token ? ` -H 'Authorization: Bearer ${token}'` : ''
  const curl = `curl -s -o /dev/null -w '%{http_code}' -X ${method}${auth} ${base}${path}`
  return { curl, expected, observed: res.status, body, headers: Object.fromEntries(res.headers) }
}
```

`expected` is what a secure service would answer and `observed` is what this one did. The two
numbers side by side are the finding, and their agreement is the negative evidence. The first
risk gets two rows, because the read and the write on the same object are checked separately, and
one of them turns out to be fine.

```js title="review.mjs"
  const read = await probe(base, { path: '/invoices/inv_1', token: 'tok_bob', expected: 404 })
  add('API1', 'GET /invoices/{id}', read.observed === read.expected ? 'none' : 'gap', 'high', read,
    `bob reads ada's invoice: ${read.observed}, expected ${read.expected}`)
  const pay = await probe(base, { method: 'POST', path: '/invoices/inv_1/pay', token: 'tok_bob', expected: 404 })
  add('API1', 'POST /invoices/{id}/pay', pay.observed === pay.expected ? 'none' : 'gap', 'high', pay,
    `bob pays ada's invoice: ${pay.observed}, expected ${pay.expected}`)
```

Not every risk is a request. Server side request forgery is a question about which routes accept
a URL, and the router answers it. Unsafe consumption is a question about which third parties the
service calls. Offline the source answers it: the sample scans `server.mjs` for call sites, where
a real review reads the egress proxy's log. Each of those rows still records what was
looked at and what was found, which is the part a spreadsheet loses. Resource consumption is a
request again, and [API4](https://api-security.owasp.org/editions/2023/en/0xa4-unrestricted-resource-consumption/)
lists the limits to ask about: page size, payload size, timeouts, and the third-party spend a
request can trigger.

Severity is one of three words, keyed on what the gap lets an outsider do. High is reading or
changing another tenant's data, or reaching an administrative function. Medium is exposure and
exhaustion. Low is a misconfiguration that needs a second bug to matter. A
[CVSS](https://www.first.org/cvss/v4.0/) score is for the advisory you may have to write later,
not for deciding which of six gaps gets fixed on Monday.

## Check it worked

The demo starts the service, enumerates it against its document, runs the ten risks, and prints
the ledger with the gaps ranked and the clean rows kept.

```bash
node demo.mjs
```

```text output
routes: 5 in the router, 3 in openapi.json, 2 undocumented: GET /admin/export, GET /healthz

rank  risk   severity  owner     endpoint                  evidence
1     API1   high      billing   GET /invoices/{id}        bob reads ada's invoice: 200, expected 404
2     API5   high      platform  GET /admin/export         export with no token: 200, expected 401
3     API9   high      -         2 routes                  2 routes missing from openapi.json, expected 0
4     API3   medium    billing   GET /invoices/{id}        response carries cost_price, expected absent
5     API4   medium    billing   GET /invoices             limit=100000 returned 100000 items, expected 400
6     API8   low       -         every route               access-control-allow-origin: *, expected absent

looked for and not found
  API1   POST /invoices/{id}/pay   bob pays ada's invoice: 404, expected 404
  API2   GET /invoices             no token: 401, expected 401
  API6   POST /invoices/{id}/pay   second payment: 409, expected 409
  API7   no route                  0 routes accept a URL, expected 0
  API10  outbound calls            0 call sites to third parties in server.mjs, expected 0

replay the top finding:
  curl -s -o /dev/null -w '%{http_code}' -X GET -H 'Authorization: Bearer tok_bob' http://127.0.0.1:36263/invoices/inv_1
```

Read the first row against the row under it. The same object, the same second tenant, and the
read leaks while the write is refused, because the write handler checks the owner and the read
handler forgot to. That is the shape of most object level authorization bugs, and it is the row a
scanner does not produce, because a scanner has no second tenant.[^3] The owner column is the team
that gets the ticket, read off the route table.

```bash
node --test review.test.mjs
```

```text output
1..4
# tests 4
# suites 0
# pass 4
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 1091.382136
```

The second test refuses a ledger with a risk missing or a row without a verdict, so a review that
skipped a risk fails rather than reading as clean. The third replays every recorded `curl` line
and asserts it gets the recorded answer. That is what makes the ledger repeatable next quarter:
run the same file, and any row whose answer moved is either a fix or a regression.

## When it goes wrong

The audit score is high and an administrative route is open. The audit read `openapi.json`, which
has three paths, and the router serves five. Run the inventory first and hand the audit tool a
document that matches it, or the tool's score is a score for a service that does not exist.

A finding replays with a different answer next quarter. The evidence depended on state the review
created: in the sample, the second payment answers 409 only because the first one went through.
Record the setup with the request. The replay test cannot see that dependency: the row it replays
is the second payment, which answers 409 with or without the setup. The sample's invoices are
module state, shared by every server the test process starts, so no replay in the suite meets
fresh state. A real ledger carries a setup line for every row like it.

The ledger says clean and the penetration testers find three things. Look at the three rows and
find the request that should have caught each one. A review with a request per row can learn
from a test; a spreadsheet of ticks cannot, because there is nothing to compare the finding with.

## When not to do this

Do not run the probes against production with real accounts. The resource consumption row asks
for a hundred thousand items on purpose, and the business flow row pays an invoice twice on
purpose. Staging with seeded data, or nothing.

Do not record a risk as clean without a request behind it. The status the sample refuses to
carry is the one most reviews are made of: not checked, filed as fine. If the true entry for a risk
is that nobody looked, write that down beside an owner and a date.

Do not treat the ledger as a penetration test. It is a list of the questions you knew to ask,
answered with evidence, and its value is that it can be re-run. A tester's value is the questions
you did not know to ask, and the two are not substitutes.

Do not spend the review on scoring. Three words of severity, keyed on what an outsider gets, put
the six gaps in an order the owning teams accept. A decimal score invites an afternoon of
argument about the second decimal place while the read on the first row stays open.

## Related how-tos

- [Test an API for broken object level authorization](/howto/test-for-broken-object-level-authorization)

- [Publish security.txt for an API](/howto/publish-security-txt-for-an-api)

## Last verified

Verified 2026-09-24 against Node 22.22.2. Every output block is what the command preceding it
printed. The service under review is the one in `server.mjs`, written with the six gaps the
ledger reports so that the probes have something to find. ZAP, 42Crunch, and a penetration
tester were not run against it: the comparison rests on what each one's own documentation says it
reads. The [ZAP API scan](https://www.zaproxy.org/docs/docker/api-scan/) imports a definition
and attacks the routes in it, so it is the tool the first step on this page exists to feed.

[^1]: Between the two editions the acronym changed its middle. The 2019 about page expands OWASP
as the [Open Web Application Security Project](https://api-security.owasp.org/editions/2019/en/0x01-about-owasp/).
The 2023 page has the
[Open Worldwide Application Security Project](https://api-security.owasp.org/editions/2023/en/0x01-about-owasp/),
with the rest of the sentence left as it was. Any review that quotes the name in full is dated
by the one word, which is more than most reviews manage.

[^2]: The [2023 introduction](https://api-security.owasp.org/editions/2023/en/0x03-introduction/)
calls the list an awareness document, and the
[release notes](https://api-security.owasp.org/editions/2023/en/0x04-release-notes/) record that
the second edition made the first public call for data and received none. The ten were chosen
from the team's experience and a review of the release candidate. A questionnaire that asks for a
review against the document is therefore asking for a review against ten informed opinions,
which is not a criticism of the opinions.

[^3]: The risk that opens both editions has a CWE behind it,
[CWE-639](https://cwe.mitre.org/data/definitions/639.html), whose title is Authorization Bypass
Through User-Controlled Key. Four of those words describe the entire bug: the caller chose the
key. The API1 page cites it under External, beside a cheat sheet, and the sample's first row is
what the title looks like with a port number on it.