Skip to content

Testing REST APIs

REST APIs expose resources at predictable URLs, which makes them a rich place to find authorization and logic bugs — the classes that automated scanners miss and that pay well. This page is a methodology for probing them from the terminal, using the tools from the HTTP recon toolkit.

Before you can test an endpoint you have to find it. Combine passive sources with careful active probing:

  • The app’s own traffic. Browse the application through your proxy with a test account; every XHR/fetch the frontend makes is an endpoint. This is the highest-signal source and the most clearly in scope.
  • JavaScript files. Single-page apps embed their API routes in JS bundles. Grep them for paths and template strings (covered in Recon & enumeration).
  • API documentation and schemas. Look for /openapi.json, /swagger.json, /api-docs, /v1/, and versioned prefixes. An OpenAPI/Swagger doc hands you every endpoint, method, and parameter.
  • Historical URLs. gau and waybackurls surface paths that existed before, including old, forgotten, or unlinked endpoints.
  • Fuzzing for undiscovered paths and parameters (below).
Terminal window
# If an OpenAPI schema is exposed, it lists every path and parameter:
curl -sS https://api.example.com/openapi.json | jq '.paths | keys'
curl -sS https://api.example.com/openapi.json \
| jq '.paths | to_entries[] | {path: .key, methods: (.value | keys)}'

APIs authenticate requests in a handful of ways. Identify which, then test what happens with and without it.

Mechanism How it travels Send it with
Session cookie Cookie: header, set after login curl -b jar.txt
Bearer / JWT Authorization: Bearer <token> curl -H 'Authorization: Bearer ...'
API key Custom header or query param curl -H 'X-Api-Key: ...'
Basic Authorization: Basic <base64> curl -u user:pass

The core authentication test is simply: does the endpoint enforce it? Send the request three ways and compare.

Terminal window
BASE=https://api.example.com
TOK='Bearer eyJhbGci...'
# 1) With valid auth — the baseline:
curl -s -o /dev/null -w 'auth: %{http_code}\n' "$BASE/orders" -H "Authorization: $TOK"
# 2) With no auth at all — should be 401:
curl -s -o /dev/null -w 'none: %{http_code}\n' "$BASE/orders"
# 3) With a malformed/expired token — should be 401, not 200 or 500:
curl -s -o /dev/null -w 'broken: %{http_code}\n' "$BASE/orders" -H 'Authorization: Bearer invalid'

If the no-auth request returns 200 and real data, that alone can be a finding. Watch for endpoints that look protected but leak data when the token is missing, empty, or malformed.

Enumerating HTTP methods and content types

Section titled “Enumerating HTTP methods and content types”

An endpoint may support methods the UI never uses. OPTIONS often advertises them via the Allow header; when it doesn’t, probe each method and read the status.

Terminal window
# Ask the server which methods it allows:
curl -s -i -X OPTIONS https://api.example.com/products/42 | grep -i '^allow:'
# Probe methods directly and compare status codes:
for m in GET POST PUT PATCH DELETE OPTIONS HEAD; do
code=$(curl -s -o /dev/null -w '%{http_code}' -X "$m" https://api.example.com/products/42)
printf '%-7s -> %s\n' "$m" "$code"
done

Some frameworks honor a method-override header or parameter, letting a POST act as a PUT or DELETE. If direct DELETE is blocked but the override isn’t, that gap is worth noting.

Terminal window
# Only try this on in-scope targets / your own store, and never delete data you didn't create:
curl -i -X POST https://api.example.com/products/42 \
-H 'X-HTTP-Method-Override: DELETE'

Content type matters too: an endpoint might behave differently with application/json vs application/x-www-form-urlencoded vs application/xml. Switching the Content-Type (and body format to match) sometimes bypasses validation or reaches a different code path.

Errors are where APIs reveal themselves. Trigger them deliberately and read carefully:

  • Verbose errors / stack traces leak framework, language, versions, file paths, and SQL — all useful context, and sometimes a finding on their own.
  • Different errors for different causes distinguish “not found” from “forbidden” from “invalid input,” which tells you an object exists even when you can’t see it.
  • Reflected input in an error can hint at injection surfaces.
Terminal window
# Compare the error for a non-existent ID vs one that exists but isn't yours:
curl -sS https://api.example.com/orders/999999999 | jq '.error // .message' # "not found"?
curl -sS https://api.example.com/orders/1001 | jq '.error // .message' # "forbidden"?

If the second returns 403 forbidden while the first returns 404 not found, the API is confirming which order IDs exist — an information leak that also sets up the next test.

IDOR / BOLA: testing object-level authorization

Section titled “IDOR / BOLA: testing object-level authorization”

IDOR (Insecure Direct Object Reference), known in the API world as BOLA (Broken Object Level Authorization), is the most common and impactful API bug: the server checks that you’re logged in but not that the specific object is yours. You change an ID in the URL or body and get someone else’s data.

The method:

  1. Create two test accounts, A and B (both yours).
  2. As account A, find an object you own and note its ID (e.g. order 1001).
  3. As account B, request account A’s object. If B can read or modify it, that’s IDOR/BOLA.
Terminal window
TOK_B='Bearer eyJ...B...' # account B's token
# B tries to read A's order. 200 + A's data = IDOR.
curl -sS https://api.example.com/orders/1001 -H "Authorization: $TOK_B" | jq '{id, email, total}'

Because you own both accounts, this proves the bug without touching a real user’s data. That is exactly why you set up two of your own accounts.

Where to look for the manipulable identifier:

  • Path segments: /orders/1001, /users/42/settings.
  • Query parameters: ?account_id=42, ?user=42.
  • Request body fields: {"orderId": 1001}.
  • Headers: a tenant/customer ID header.

Sequential integer IDs are the easy case. When IDs are UUIDs or hashes, IDOR is harder but not impossible — you may still obtain another object’s ID from a different endpoint (a listing, an email, a referral link) and then access it directly.

Mass assignment (a.k.a. autobinding / over-posting) happens when an API blindly maps your JSON fields onto a database object, letting you set attributes you shouldn’t — like role, isAdmin, verified, balance, owner_id, or price.

Find the object’s real shape from a GET, then send fields the UI never lets you set.

Terminal window
# See every field the API returns for your own user — candidates to try setting:
curl -sS https://api.example.com/users/me -H "Authorization: $TOK" | jq 'keys'
# Attempt to set a privileged field during a normal profile update (your own account):
curl -sS -X PATCH https://api.example.com/users/me \
-H "Authorization: $TOK" -H 'Content-Type: application/json' \
--data-raw '{"name":"Test","role":"admin","verified":true}' \
| jq '{role, verified}'

If the response (or a follow-up GET) shows role: "admin", the extra field was accepted. Confirm the effect is real (e.g. you can now reach an admin-only endpoint) before reporting — on your own account, and without using the elevated access for anything else.

Beyond mass assignment, many logic bugs come from changing values the server trusts too much:

  • Price / quantity / discount fields in a purchase flow (negative quantities, zero price, oversized discounts).
  • State fields like status, paid, approved.
  • Pagination and limit values (limit=100000) that may bypass intended caps.
  • Filters that a false or unexpected type turns off (?published=false revealing drafts).
Terminal window
# Does the API accept a nonsensical value it should reject? (your own store / in scope only)
curl -sS -X POST https://api.example.com/cart \
-H "Authorization: $TOK" -H 'Content-Type: application/json' \
--data-raw '{"productId":42,"quantity":-1}' | jq .

The signal is a 200 where you expected a 400, or a business state that shouldn’t be reachable. Verify the actual consequence rather than assuming from the status alone.

The terminal shines when you need the same request across a range of inputs. A loop over IDs, throttled and logged, turns a manual check into evidence.

Terminal window
BASE=https://api.example.com
TOK='Bearer eyJhbGci...'
# Walk a small range of order IDs and record each status code.
# Keep the range SMALL and the delay real — you are testing enforcement, not scraping data.
for id in $(seq 1000 1010); do
code=$(curl -s -o /dev/null -w '%{http_code}' \
"$BASE/orders/$id" -H "Authorization: $TOK")
printf '%s -> %s\n' "$id" "$code"
sleep 1 # respect rate limits
done

A row of 200s where you should only own one of those orders points at IDOR. A mix of 403 and 404 maps which IDs exist.

Terminal window
# A polite loop that backs off when the server says to:
for id in $(seq 1 20); do
resp=$(curl -s -w '\n%{http_code}' "$BASE/items/$id" -H "Authorization: $TOK")
code=$(tail -n1 <<<"$resp")
[ "$code" = "429" ] && { echo "rate limited at $id — stopping"; break; }
echo "$id: $code"
sleep 1
done

ffuf (“Fuzz Faster U Fool”) replaces a keyword in a request with each line of a wordlist, at speed. The keyword is FUZZ by default. It’s ideal for discovering hidden endpoints, parameter names, and accepted values.

Discover endpoints under a path:

Terminal window
ffuf -u https://api.example.com/FUZZ \
-w /usr/share/seclists/Discovery/Web-Content/api/api-endpoints.txt \
-mc 200,201,401,403 \
-rate 20

Key filtering flags — you use these constantly, because the point is to find the anomalous response:

  • -mc match status codes (default set already includes common ones); -fc filter them out.
  • -fs filter by response size, -fw by word count, -fl by line count — hide the uniform “not found” responses so the interesting one stands out.
  • -ac autocalibration: ffuf learns what “not found” looks like and filters it automatically.
  • -t threads, -rate requests/second (use this to stay polite), -p delay between requests.
Terminal window
# Filter out the boilerplate 404 (say it's 24 words) so real hits surface:
ffuf -u https://api.example.com/FUZZ \
-w wordlist.txt -fw 24 -rate 20 -ac

Fuzz a parameter’s value — e.g. test IDs or filter values (throttled, in scope):

Terminal window
ffuf -u 'https://api.example.com/orders?status=FUZZ' \
-w statuses.txt -rate 10 -mc all -fs 0

Discover parameter names by fuzzing the key and watching for a response that changes:

Terminal window
ffuf -u 'https://api.example.com/products?FUZZ=1' \
-w /usr/share/seclists/Discovery/Web-Content/burp-parameter-names.txt \
-mc all -ac -rate 20

Fuzz a POST body with a custom keyword and header:

Terminal window
ffuf -u https://api.example.com/search \
-X POST \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer eyJhbGci..." \
-d '{"query":"FUZZ"}' \
-w terms.txt -mc all -rate 20 -o ffuf-search.json -of json

Two behaviors shape everything you script:

  • Rate limits appear as 429 Too Many Requests, often with Retry-After and X-RateLimit-* headers telling you the ceiling and reset. Read them and obey them. Notably, an endpoint with no limit on a sensitive action (login, password reset, coupon apply) can itself be a finding — report the missing limit; don’t exploit it by brute force.
Terminal window
curl -sD - -o /dev/null https://api.example.com/products | grep -i 'ratelimit\|retry-after'
  • Pagination controls how much data comes back — ?page=2, ?limit=50, cursor tokens, or Link: rel="next" headers. Understand it before looping so you don’t accidentally request everything, and check whether oversized limit values bypass intended caps.
Terminal window
curl -sD - -o /dev/null https://api.example.com/products?page=1 | grep -i '^link:'

You will test dozens of endpoints; memory won’t hold it. Keep a simple, greppable log per target: the endpoint, method, what you tried, the status, and whether it’s worth revisiting. Save the exact curl command for anything promising so your eventual report is copy-paste.

notes/api-map.md
| Endpoint | Method | Auth? | Notes |
| ------------------- | ------ | ------ | -------------------------------------- |
| /orders/{id} | GET | bearer | 403 for others' IDs — good (no IDOR) |
| /users/me | PATCH | bearer | accepted role:admin ?? -> CONFIRM |
| /internal/metrics | GET | none | 200 without auth -> info leak, revisit |
  • Find endpoints from the app’s own traffic, JS, docs/OpenAPI, archives, and targeted fuzzing.
  • Test auth by sending each request with valid, missing, and broken credentials — a 200 where you expected 401/403 is the bug.
  • IDOR/BOLA is the highest-value class: use two of your own accounts to prove cross-object access without touching real users’ data.
  • Probe methods (OPTIONS, overrides), content types, mass-assignable fields, and tampered values; read error messages for leaks and existence oracles.
  • Script with small, throttled bash loops and ffuf (always -rate-limited), honor 429/pagination, and keep structured, copy-paste notes for your report.