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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| A manual review | 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 | 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 | 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 | 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,
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.
Related how-tos
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
-
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
-
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
-
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