Most API pentest guides break the OWASP list into eleven unrelated chores. But that order misses the quickest checks. A second identity and raw JSON usually expose authorization failures before payload hunting does.
And test only a staging or deliberately vulnerable lab API with written permission. You need two test identities with different ownership or roles, valid tokens, Bash with curl and jq, and an agreed request ceiling and stop condition. Burp Suite is useful for interception and replay; OWASP ZAP is a reasonable free option for automated baseline scans.
Start with a lab API at https://api.shop.test. The fixture gives Alice order 1001 and Bob order 1002. Use equivalent test data in your environment. We’ll discover routes, replay objects between identities, inspect tokens and fields, measure limits, test business flows, and trace user-controlled URLs. The OWASP API Security Top 10 remains the 2023 edition as of August 15, 2026; use it to label findings while the test order follows identity and authorization boundaries first.
In this article
- Scope is a control, not a disclaimer
- Build the route inventory before testing controls
- Two identities expose object authorization
- Treat tokens as inputs, not facts
- Compare returned fields with writable fields
- Measure limits and test the identity they trust
- Separate function access from business-flow abuse
- Follow values to candidate sinks
- Report the request, response, and state change
Scope is a control, not a disclaimer
Record the target hosts and excluded paths. Note the test accounts and data-handling rules. Add test hours, request-rate limits, and the person to contact if the service misbehaves. Decide in advance what stops a test: rising error rates, queue growth, unexpected data exposure, or any production traffic.
Set up the harness. The proxy variable sends command-line traffic through Burp or another approved interceptor. Import its CA only according to your lab’s approved setup.
export HTTPS_PROXY=http://127.0.0.1:8080
BASE=https://api.shop.test
TOKEN_A='alice-test-token'
TOKEN_B='bob-test-token'
The values below stand in for an authorized target. OWASP’s API Security Project supplies the risk framework; it does not replace request-and-response evidence. Begin with BOLA, broken authentication, BOPLA, and BFLA. Those controls establish who can access an object, become an identity, change a property, or call a function.
Build the route inventory before testing controls
The SPA’s route list is a discovery lead, not a coverage boundary. Check common specifications and identity metadata. What does the API reveal?
for p in swagger-ui api-docs v3/api-docs openapi.json \
.well-known/openid-configuration; do
curl -s -o /dev/null -w "%{http_code} $p\n" "$BASE/$p"
done
If the OpenAPI document is protected, repeat with an approved test token. A 401 means “authentication required.” The documentation may still be present.
curl -fsS "$BASE/openapi.json" |
jq -er 'if (.paths | type) == "object" then .paths | keys[] else error("no object paths") end' \
> endpoints.txt
Here -f rejects HTTP errors and -e makes a null result fail. A malformed document already causes jq to fail.
For a reachable staging front end, extract API-looking URLs from JavaScript:
katana -u https://app.shop.test -jc -silent |
grep -E '/api|/v[0-9]' |
sort -u > discovered-urls.txt
Keep full URLs separate from relative OpenAPI paths. Tools such as nuclei need reachable URLs:
nuclei -l discovered-urls.txt -t http/exposures/
Probe old versions using the prefix your API actually uses:
for v in v1 v2 v3 internal beta; do
curl -s -o /dev/null -w "%{http_code} /api/$v/users\n" \
"$BASE/api/$v/users"
done
A successful documentation response helps you find leads. It does not rate their severity. Record exposed docs, deprecated routes, stack traces, and client-side secrets separately. Restrict production documentation, retire old versions, suppress traces, and remove shipped keys.
If the inventory reveals /api/orders/{id}, begin there.
Two identities expose object authorization
Replay an object with the wrong identity
BOLA is the fastest useful demonstration. Hold the method, path shape, headers, and token constant while changing only the object ID. Then reverse both identities.
Alice reads her order:
curl -s "$BASE/api/orders/1001" \
-H "Authorization: Bearer $TOKEN_A" |
jq .
Now Alice requests Bob’s order:
curl -i -s "$BASE/api/orders/1002" \
-H "Authorization: Bearer $TOKEN_A"
A 403 or non-disclosing 404 may be correct. Ownership and returned data matter more than the status alone. A response containing Bob’s order, or a state change on it, is the finding.
Repeat in the other direction:
curl -i -s "$BASE/api/orders/1001" \
-H "Authorization: Bearer $TOKEN_B"
UUIDs make identifiers harder to guess, but they do not enforce authorization. Test identifiers in paths, query strings, JSON bodies, headers, nested resources, batch arrays, exports, downloads, and every supported method. A read check may exist while update or delete remains open.
Repeat across locations and methods
Use ffuf only with known test IDs and a low request rate:
ffuf -u "$BASE/api/orders/FUZZ" \
-w <(printf '%s\n' 1001 1002) \
-H "Authorization: Bearer $TOKEN_A" \
-mc 200,403,404 \
-rate 1
A scanner’s green result here is almost worthless. Save the original request and the replay. Record how the responses differ. Note the affected identity boundary and any state change. That is the evidence.
Treat tokens as inputs, not facts
A readable JWT proves encoding. It says nothing about signature verification or claim validation.
This decoder handles common base64url padding:
JWT="$TOKEN_A"
python3 - "$JWT" <<'PY'
import base64, json, sys
for part in sys.argv[1].split(".")[:2]:
part += "=" * (-len(part) % 4)
print(json.dumps(
json.loads(base64.urlsafe_b64decode(part)),
indent=2
))
PY
Inspect alg, kid, iss, aud, sub, roles, and expiry. Manual claim replay is the primary test. For versions supporting these modes, jwt_tool can generate variants:
jwt_tool --help
jwt_tool "$JWT" -T
jwt_tool "$JWT" -X a
A generated variant is only a test artifact. Report algorithm confusion or alg:none acceptance only if the server accepts the token and changes access.
In a lab using test-issued tokens and test keys, you may check weak HMAC secrets:
jwt_tool "$JWT" -C -d /usr/share/wordlists/rockyou.txt
For a key-reference test, use only an approved lab key source:
jwt_tool "$JWT" -X k -pk ./lab-issuer-public.pem
Check altered payloads, invalid signatures, expired tokens, wrong aud or iss, logout replay, and refresh-token reuse. The presence of kid, jku, or x5u is not a finding by itself. Test whether those references stay restricted to trusted keys, and keep requests inside the lab.
The fix starts with an algorithm allowlist and signature and claim validation. Restrict key references. Rotate refresh tokens, revoke them, and detect reuse. The weak point in this method is asynchronous behavior: curl may show a successful enqueue while dangerous work happens later. Confirm those cases with logs or an owner; don’t infer impact from the HTTP response.
Compare returned fields with writable fields
BOPLA covers reads and writes:
| Direction | Concrete evidence | Fix |
|---|---|---|
| Read exposure | A role-appropriate response includes a field it should omit | Return an explicit response DTO |
| Write overposting | A later GET shows isAdmin, ownership, or another protected value changed | Enforce a server-side writable-field allowlist |
Inspect Alice’s complete response, while redacting secrets:
curl -s "$BASE/api/users/me" \
-H "Authorization: Bearer $TOKEN_A" |
jq .
Test one field at a time against disposable data. Reset the lab account after each mutation:
curl -i -s -X PATCH "$BASE/api/users/me" \
-H "Authorization: Bearer $TOKEN_A" \
-H "Content-Type: application/json" \
-d '{"verified":true}'
Only where the test environment permits it, try protected fields such as role, isAdmin, ownerId, or balance. An echoed field is a lead. A subsequent read showing changed privilege or ownership is evidence.
Arjun can suggest JSON parameters, though its body handling varies by version:
arjun -u "$BASE/api/users/me" \
-m JSON \
-H "Authorization: Bearer $TOKEN_A"
Measure limits and test the identity they trust
A scanner’s green result here is almost worthless. Rate limiting is an identity and cost-control question, not a count from one source address.
Send a bounded login test:
for i in $(seq 1 20); do
curl -s -o /dev/null -w "$i %{http_code}\n" \
-X POST "$BASE/api/login" \
-H "Content-Type: application/json" \
-d "{\"user\":\"alice-test\",\"pass\":\"wrong-$i\"}"
sleep 0.2
done
curl -s -D - -o /dev/null \
-X POST "$BASE/api/login" \
-H "Content-Type: application/json" \
-d '{"user":"alice-test","pass":"wrong-final"}'
When requests 1–8 return 401 and request 9 returns 429, record a window of eight attempts. Repeat after the reset and record Retry-After, if present.
Hold token, path, payload, timing, and request count constant while comparing baseline and forwarded headers:
for mode in baseline rotated; do
printf '%s: ' "$mode"
for i in $(seq 1 5); do
args=()
[ "$mode" = rotated ] &&
args=(-H "X-Forwarded-For: 192.0.2.$i")
curl -s -o /dev/null -w "%{http_code} " \
"$BASE/api/search?q=test" "${args[@]}"
done
printf '\n'
done
More successful requests under rotation suggest that a spoofable header influences the limit. They do not prove a bypass on their own.
Check pagination and expensive routes with small requests:
for q in 'limit=9999999' 'per_page=100000' 'page_size=-1'; do
curl -s "$BASE/api/items?$q" -o /dev/null \
-w "$q status=%{http_code} bytes=%{size_download}\n"
done
Test exports, OTP sends, batch routes, and GraphQL depth or batching only when they are in scope. Stop at the agreed ceiling.
For the lab API’s no-charge coupon, test state rather than load:
for i in $(seq 1 3); do
curl -s -X POST "$BASE/api/apply-coupon" \
-H "Authorization: Bearer $TOKEN_A" \
-H "Content-Type: application/json" \
-d '{"code":"SAVE50"}'
printf '\n'
done
The first application should alter the test cart. Later attempts should be rejected or leave it unchanged.
Separate function access from business-flow abuse
Alice’s regular token should not reach administrative data:
curl -i -s "$BASE/api/admin/users" \
-H "Authorization: Bearer $TOKEN_A"
Test documented methods, versions, bulk routes, and any method-override behavior:
curl -i -s -X POST "$BASE/api/orders/1001" \
-H "Authorization: Bearer $TOKEN_A" \
-H "X-HTTP-Method-Override: DELETE"
Compare the response and state with a normal request against disposable data. Every method and version needs default-deny authorization.
Then test sensitive flows with low counts. Repeat the coupon request above, and if the inventory contains referral or quantity controls, test those with disposable accounts. For /api/checkout, use only no-charge products and a small agreed request count. A successful status matters when it proves an unauthorized state transition. It can also show a duplicate discount or quantity violation.
Follow values to candidate sinks
Treating injection as one SQLi string is lazy testing. First find where each user-controlled value goes, then use harmless probes and confirm the sink.
| Input | Hypothesized sink | Safe probe | Evidence |
|---|---|---|---|
sort or filter | Database or search parser | Change among documented values | Reflection, parser error, or controlled result change |
| Template-like field | Template renderer | Send a unique inert marker | Unexpected server-side rendering |
| JSON or XML field | Parser or downstream service | Add one benign unknown field | Parser or downstream behavior |
url or callback_url | Server-side HTTP client | Send an approved callback URL | Out-of-band request metadata |
The lab’s /api/fetch endpoint accepts a URL:
curl -i -s -X POST "$BASE/api/fetch" \
-H "Authorization: Bearer $TOKEN_A" \
-H "Content-Type: application/json" \
-d '{"url":"<YOUR_CONTROLLED_CALLBACK_URL>"}'
A blind fetch may return no useful body; the callback observer supplies the evidence. Record timestamp, source address, method, and path.
Then test a controlled internal service:
curl -i -s -X POST "$BASE/api/fetch" \
-H "Authorization: Bearer $TOKEN_A" \
-H "Content-Type: application/json" \
-d '{"url":"http://127.0.0.1:8080/health"}'
A response from the lab service strongly suggests that the fetch path reached it. Confirm the origin with service logs or callback data. Use alternate loopback representations only against the controlled staging network, never production internal addresses. Do not request cloud metadata credentials; proving controlled internal reachability is enough to establish the SSRF path.
If the inventory contains source_url, link, image_url, or webhook, apply the same process. For SQL, NoSQL, command, template, and parser hypotheses, record reflection, errors, timing, and downstream behavior. Don’t claim a sink from a strange error alone.
Report the request, response, and state change
Finding checklist
Use the 2023 labels. BOPLA is A03, unrestricted resource consumption is A04, sensitive business flows are A06, and SSRF is A07. OWASP classification does not determine severity by itself.
Before closing the assessment, verify:
- endpoint and method
- identity and role used
- original and replayed request
- response and relevant JSON difference
- affected data or state
- OWASP ID and remediation
- retest condition
A checklist gives coverage. The request/response/state trio gives you a finding.