How-to › Release and secure packages

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.

Audience
API producer
Level
intermediate
Topic
Harden clients and servers
Verified

A customer’s security questionnaire asks whether the API has been reviewed against the OWASP API Security Top 10.1 The answer on file is a spreadsheet with ten green cells and no requests behind them. Nobody can say which endpoints were looked at. Next quarter’s review starts from nothing, because there is nothing to replay.

What you get

You will end up with a ledger that gives every one of the ten risks its endpoints, a replayable request, the answer it got, and an owner, gaps ranked first. This is for you if you own a running API and the last review left no evidence.

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, which calls itself an awareness document and not a standard.2

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
A manual reviewThe authorization rows, which need someone who knows which user may see which rowA day or two of a person who knows the data model, and the discipline of recording every requestYou have no such person and need a first pass this week
ZAP baseline scanEvery pull request, against staging, for what a passive scan can seeHeaders, cookies, and error pages only: it sends no attack and cannot know that one user is not anotherThe question is who may read what, which no passive scan answers
42Crunch auditAn OpenAPI document that is the contract, checked for missing security schemes and loose data definitionsIt reads the document rather than the service, so a route the document omits is invisible to itThe document and the router have drifted, which the first step below measures
A penetration testA release, an acquisition, or a customer who asks for the report by nameMoney, a scope fixed in advance, and a report that starts aging the day the next endpoint shipsYou 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, before you have sent a request. Every router keeps a table. Fastify prints its own with printRoutes(), 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.

// 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 to 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.

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.

  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 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 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.

node demo.mjs
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.

node --test review.test.mjs
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.

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 imports a definition and attacks the routes in it, so it is the tool the first step on this page exists to feed.

Footnotes

  1. Between the two editions the acronym changed its middle. The 2019 about page expands OWASP as the Open Web Application Security Project. The 2023 page has the Open Worldwide Application Security Project, 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. ↩︎ Back to text

  2. The 2023 introduction calls the list an awareness document, and the 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. ↩︎ Back to text

  3. The risk that opens both editions has a CWE behind it, CWE-639, 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. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.