Testing Your Own API
A structured self-assessment you can run in a day, covering what a scanner cannot.
You do not need a security consultancy to find your own most likely problems. You have something a tester does not: knowledge of what the data means and who should reach it.
Set up properly first
You need at least: two users in one tenant, one user in another tenant, one admin, one read-only account, and objects owned by each. Without a second tenant, isolation is untestable.
The order that finds the most, fastest
- Authorization — object, function, tenant. This is where the findings are, and no tool covers it.
- Mass assignment and data exposure — send extra fields, inspect every response.
- Resource limits — oversized pages, deep offsets, huge bodies.
- Input handling — injection payloads, type confusion, malformed input.
- Configuration — headers, CORS, debug surfaces, exposed files.
- Business logic — the flows that assume a human.
Work from your own documentation
Your OpenAPI spec is a complete map. Walk it endpoint by endpoint rather than exploring, and you will not skip the ones nobody talks about.
Record what you did, not only what you found
"Tested all 47 endpoints for BOLA with two accounts across two tenants; three findings" is evidence. "Looked for IDOR" is not, and a month later nobody knows what was covered.
Rules of engagement
Test in staging with production-like configuration. Get written authorisation. Never test third-party services you merely integrate with. And never run a destructive test against real customer data.
Example
# Fixtures first. Without these, isolation is untestable.
export ALICE=$(login [email protected]) # acme tenant
export BOB=$(login [email protected]) # acme tenant, different user
export CAROL=$(login [email protected]) # DIFFERENT tenant
export VIEWER=$(login [email protected]) # read-only
export ADMIN=$(login [email protected])
# One object of each type, owned by each
export ALICE_INVOICE=$(create_invoice "$ALICE")
export BOB_INVOICE=$(create_invoice "$BOB")
export CAROL_INVOICE=$(create_invoice "$CAROL")
# Now every test below is a one-liner.When to use it
- A one-day self-assessment finds three BOLA issues that two automated scanners had reported nothing about.
- Walking the OpenAPI spec endpoint by endpoint reveals an admin route nobody on the team remembered existed.
- A documented test run gives an auditor evidence of coverage rather than an assertion that testing happened.
More examples
The authorization pass, which finds the most
The last check is the one people never think to run: identical status codes for 'exists but forbidden' and 'does not exist' is what closes the enumeration oracle.
#!/usr/bin/env bash
# Run this first. It is where the findings are.
set -uo pipefail
API="${API:-https://staging.dfg.com}"
FINDINGS=0
t() { # t <expected> <description> <curl args...>
local want="$1" desc="$2"; shift 2
local got; got=$(curl -s -o /dev/null -w '%{http_code}' "$@")
if [ "$got" != "$want" ]; then
echo "❌ [$got, want $want] $desc"; FINDINGS=$((FINDINGS+1))
else
echo "✅ [$got] $desc"
fi
}
echo "── BOLA: another user, same tenant ──"
t 404 "read Bob's invoice" "$API/api/invoices/$BOB_INVOICE" -H "Authorization: Bearer $ALICE"
t 404 "update Bob's invoice" -X PATCH "$API/api/invoices/$BOB_INVOICE" \
-H "Authorization: Bearer $ALICE" -H 'Content-Type: application/json' -d '{"note":"x"}'
t 404 "delete Bob's invoice" -X DELETE "$API/api/invoices/$BOB_INVOICE" \
-H "Authorization: Bearer $ALICE"
echo "── Tenant isolation ──"
t 404 "read Carol's invoice (other tenant)" \
"$API/api/invoices/$CAROL_INVOICE" -H "Authorization: Bearer $ALICE"
t 404 "admin of acme cannot read globex" \
"$API/api/invoices/$CAROL_INVOICE" -H "Authorization: Bearer $ADMIN"
echo "── BFLA ──"
t 403 "normal user on an admin route" "$API/api/admin/users" -H "Authorization: Bearer $ALICE"
t 401 "admin route, anonymous" "$API/api/admin/users"
t 403 "read-only account cannot write" -X POST "$API/api/invoices" \
-H "Authorization: Bearer $VIEWER" -H 'Content-Type: application/json' -d '{}'
echo "── The paths people forget ──"
t 404 "nested: Bob's invoice line" \
"$API/api/invoices/$BOB_INVOICE/lines" -H "Authorization: Bearer $ALICE"
t 404 "bulk endpoint with a foreign id" -X POST "$API/api/invoices/bulk" \
-H "Authorization: Bearer $ALICE" -H 'Content-Type: application/json' \
-d "{\"ids\":[\"$BOB_INVOICE\"]}"
t 404 "old version of the same route" \
"$API/api/v1/invoices/$BOB_INVOICE" -H "Authorization: Bearer $ALICE"
echo "── Export and search: the same rows, a different path ──"
curl -s "$API/api/invoices/export" -H "Authorization: Bearer $ALICE" \
| grep -q "$BOB_INVOICE" && { echo "❌ export leaks Bob's invoice"; FINDINGS=$((FINDINGS+1)); }
curl -s "$API/api/search?q=invoice" -H "Authorization: Bearer $ALICE" \
| grep -q "$CAROL_INVOICE" && { echo "❌ search leaks across tenants"; FINDINGS=$((FINDINGS+1)); }
echo "── 403 vs 404: does a 403 confirm existence? ──"
real=$(curl -s -o /dev/null -w '%{http_code}' "$API/api/invoices/$BOB_INVOICE" \
-H "Authorization: Bearer $ALICE")
fake=$(curl -s -o /dev/null -w '%{http_code}' "$API/api/invoices/00000000-0000-0000-0000-000000000000" \
-H "Authorization: Bearer $ALICE")
[ "$real" != "$fake" ] && { echo "❌ status codes differ ($real vs $fake) — existence oracle"; \
FINDINGS=$((FINDINGS+1)); }
echo; echo "Findings: $FINDINGS"
exit $(( FINDINGS > 0 ))Everything else, in one pass
Checking the deep-offset response time rather than only its status is what reveals a pagination performance problem that a status-code check would call healthy.
#!/usr/bin/env bash
API="${API:-https://staging.dfg.com}"
echo "── Mass assignment: send fields the form never sends ──"
curl -s -X PATCH "$API/api/users/me" -H "Authorization: Bearer $ALICE" \
-H 'Content-Type: application/json' \
-d '{"role":"admin","isAdmin":true,"tenantId":"other","emailVerified":true,
"balance":999999,"__proto__":{"isAdmin":true}}'
curl -s "$API/api/users/me" -H "Authorization: Bearer $ALICE" \
| jq '{role, isAdmin, tenantId, balance}'
# Any of these actually changing is a critical finding.
echo "── Data exposure: what comes back that should not? ──"
for path in /api/users/me /api/invoices /api/projects; do
echo "$path:"
curl -s "$API$path" -H "Authorization: Bearer $ALICE" \
| jq -r 'paths(scalars) | join(".")' | sort -u \
| grep -iE 'password|secret|token|hash|ssn|internal|risk|key' \
&& echo " ❌ sensitive field exposed"
done
echo "── Resource limits ──"
curl -s "$API/api/invoices?limit=1000000" -H "Authorization: Bearer $ALICE" \
| jq 'if type=="array" then length else (.data|length) end'
# > 100 means the cap is missing
curl -s -o /dev/null -w 'deep offset: %{time_total}s\n' \
"$API/api/invoices?offset=5000000" -H "Authorization: Bearer $ALICE"
# multiple seconds means offset pagination with no cursor
head -c 50000000 /dev/zero | tr '\0' 'a' > /tmp/big.txt
curl -s -o /dev/null -w 'big body: %{http_code}\n' -X POST "$API/api/invoices" \
-H "Authorization: Bearer $ALICE" -H 'Content-Type: application/json' \
--data-binary "@/tmp/big.txt"
# expect 413
echo "── Injection and type confusion ──"
for payload in "' OR '1'='1" '"; DROP TABLE x;--' '{"$ne":null}' \
'../../../etc/passwd' '<script>alert(1)</script>' '${jndi:ldap://x}'; do
code=$(curl -s -o /dev/null -w '%{http_code}' "$API/api/invoices?status=$(jq -rn --arg v "$payload" '$v|@uri')" \
-H "Authorization: Bearer $ALICE")
echo " $code $payload"
# 500 = unhandled. 200 with unexpected data = worse.
done
echo "── SSRF ──"
for url in http://169.254.169.254/latest/meta-data/ http://localhost:6379/ \
file:///etc/passwd http://10.0.0.1/; do
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$API/api/import" \
-H "Authorization: Bearer $ALICE" -H 'Content-Type: application/json' \
-d "{\"url\":\"$url\"}")
echo " $code $url" # expect 400
done
echo "── Configuration ──"
./scripts/misconfig-scan.sh "$API"
echo "── Rate limiting ──"
for i in $(seq 1 40); do
curl -s -o /dev/null -w '%{http_code} ' -X POST "$API/auth/login" \
-H 'Content-Type: application/json' -d '{"email":"[email protected]","password":"wrong"}'
done; echo
# expect 401s becoming 429sRecording it as evidence
Recording what was tested and found clean is what makes the document evidence — a findings-only report cannot distinguish thorough testing from a quick look.
# security/assessments/2026-08-04.md
## Self-assessment — API, staging
Tester: @name Date: 2026-08-04 Duration: 6 hours
Environment: staging (production-like configuration, synthetic data)
Authorisation: approved by @security-lead 2026-08-01
### Scope
- 47 endpoints, from openapi.json (commit a1b2c3d)
- Excluded: /api/v0/* (decommissioned, confirmed not deployed)
- Not tested: third-party integrations (Stripe, Auth0) — not ours to test
### Coverage — what was DONE, not only what was found
| Category | Endpoints | Method |
|---|---|---|
| BOLA | 31 (all taking an id) | 2 users, 2 tenants, all methods |
| BFLA | 12 (admin/internal) | 4 caller roles including cross-tenant admin |
| Mass assignment | 18 (accepting a body) | 7 privileged fields each |
| Data exposure | 47 | field scan, 5 viewer roles |
| Resource limits | 14 (collections) | limit, offset, body size |
| Injection | 47 | 12 payloads per parameter |
| SSRF | 3 (accepting a URL) | 18 payloads |
| Configuration | n/a | external scan |
| Business logic | 4 flows | checkout, invite, export, reset |
### Findings
| # | Severity | Category | Endpoint | Description | Status |
|---|---|---|---|---|---|
| 1 | CRITICAL | BOLA | GET /api/invoices/{id}/lines | Nested route does not verify the parent belongs to the caller | FIXED a1b2c3d |
| 2 | HIGH | Exposure | GET /api/users/{id} | Returns signupIp and riskScore to any authenticated user | FIXED d4e5f6a |
| 3 | MEDIUM | Limits | GET /api/audit-log | limit is uncapped; 100k rows returned | FIXED |
| 4 | LOW | Config | all | Missing Referrer-Policy | FIXED |
| 5 | INFO | — | GET /api/search | Deep offsets are slow (4s at offset 1M) | ticket #4821 |
### Not found — worth recording
- No SQL injection (parameterised throughout; 47 endpoints × 12 payloads)
- No mass assignment (schema validation with .strict() everywhere)
- Tenant isolation held across all 31 object endpoints
### Retest
2026-08-06: findings 1–4 verified fixed; full BOLA pass re-run, clean.
### Next assessment
2026-11-04, or on any change to the authorization model.
# The "not found" section is what an auditor asks for and most reports omit.
# It is the difference between "we tested" and "here is what we tested".
Discussion