
Salt Security's research has tracked malicious API traffic climbing into the hundreds of percent year over year, and Gartner called it years ago: APIs are now the most frequent attack vector for web applications. The reason is unglamorous. An API hands an attacker the raw data contract, and a single missing ownership check can leak an entire table one integer at a time. That is the bug class API penetration testing exists to catch, and it is exactly the class your DAST scanner reports as clean.
This guide is the working version, not the brochure version. You will see the actual request that proves a Broken Object Level Authorization bug, a forged alg:none token, a cracked JWT secret, a findings table from a real report, and the configuration changes that close each gap. If you run any kind of API, this is the assessment that finds what the front end was hiding.
API penetration testing is the manual and AI-assisted exercise of attacking an application's API endpoints directly, looking for flaws in authorization, authentication, input handling, and business logic that a user interface would normally hide. You work against the raw request and response: forging object IDs, replaying one account's token against another's data, sending fields the client never renders, and hammering endpoints that forgot to rate-limit.
The work is structured around the OWASP API Security Top 10, which names the risk classes worth your time. The top of that list is not injection. It is authorization, because an API has no server-rendered template deciding what you get to see.
That is the core insight the rest of this guide builds on. A scanner can tell you a header is missing. It cannot tell you that GET /api/v1/orders/1043 just returned a different tenant's order, because it has no concept of who owns object 1043. A tester with two accounts can prove it in one request, and that proof is what you are paying for.
This is the single biggest difference from web application testing, and it reframes everything. A web app pentest often starts by crawling rendered pages to discover functionality. An API pentest starts from the contract itself: an OpenAPI or Swagger file, a Postman collection, a GraphQL introspection dump, or captured mobile traffic. Every parameter the backend will accept is in scope, documented or not.
Three consequences follow directly:
is_staff flag to any client, trusting the front end to hide it. When you talk to the API directly, the front end is not your problem./api/v1/ often survives a rewrite to /api/v2/, with weaker or absent auth on the deprecated path. That is OWASP API9, Improper Inventory Management, and it is a reliable foothold.You authenticate as user A, capture a request that fetches an object by ID, then replay it pointed at an object you do not own. The status code tells you everything. Here is the exchange that proves it, lightly annotated:
GET /api/v1/accounts/2001/statements HTTP/1.1
Host: bank.example.com
Authorization: Bearer <user-A-token> # token belongs to user A
--- response ---
HTTP/1.1 200 OK # <- expected 403, got 200
{ "account": 2001,
"owner": "userB@example.com", # <- this is NOT user A
"balance": 84210.55 }User A's token returned user B's bank statement. That 200 is the proof. Because the ID is a sequential integer, the bug is exploitable at scale by scripting the increment, which is why it scores critical rather than medium.
The dangerous sibling is mass assignment, where the framework binds every JSON key in the body straight onto the model. A profile update accepts fields the client never shows:
PATCH /api/v1/users/me HTTP/1.1
Authorization: Bearer <low-priv-token>
Content-Type: application/json
{ "displayName": "akhil",
"role": "admin", # <- field the UI never sends
"emailVerified": true }
--- response ---
HTTP/1.1 200 OK
{ "id": 88, "displayName": "akhil",
"role": "admin" } # <- server bound it: you are now adminThe server echoed "role": "admin" back, which means it accepted the smuggled field and you just escalated yourself. Two requests, two critical findings, zero scanner involvement.
One caveat keeps testers honest here: a 200 alone is not always a BOLA. If the object you requested happens to be public, or the API returns a sanitized stub for objects you do not own, you have a false positive. The discipline is to confirm with a second account: fetch object 2001 as user A and as user B, and compare. If both see the full record, the ownership check is missing; if only the owner sees the sensitive fields, it is enforced. That paired comparison is the difference between a finding and a guess, and it is the exact step a scanner cannot take.
You attack the token itself, because a bearer token the server trusts is the cheapest route to every other user. JWTs fail in a few repeatable ways. The first is honoring alg:none: you rewrite the header, strip the signature, and tamper a claim. A library that respects none accepts a forged, unsigned token:
# Original header: {"alg":"HS256","typ":"JWT"}
# Forged header: {"alg":"none","typ":"JWT"}
# Forged payload: {"sub":"88","role":"admin"} # <- escalated claim
$ python3 jwt_tool.py <token> -X a # -X a = alg:none exploit
[+] Signature removed, alg set to none
[+] Forged token:
eyJhbGciOiJub25lIn0.eyJzdWIiOiI4OCIsInJvbGUiOiJhZG1pbiJ9.
# <- empty signature, accepted by server = criticalThe second is a weak HMAC secret. If the token is HS256-signed with a guessable key, you crack it offline and then sign anything you like:
$ hashcat -a 0 -m 16500 token.jwt rockyou.txt # -m 16500 = JWT mode
eyJhbGciOiJIUzI1NiJ9...<snip>:s3cr3t-key-2019 # <- secret recovered
Session..........: hashcat
Status...........: CrackedWith the secret in hand you sign a token carrying "role":"admin" and walk in. The other repeatable checks are kid header injection (point it at ../../dev/null or an SQL fragment for key confusion) and a flipped signature byte that still returns 200, which means the server never verified it at all.
It delivers categorized findings, each proven with a paired request and response and scored with CVSS, plus a remediation that names the exact control. The table below is the shape of a real report excerpt: this is what experience looks like on paper, not a list of scanner hits.
On a recent assessment of a fintech lender, the documented API looked locked down, but a capture from the mobile app revealed an undocumented /internal/v1/ namespace with no authorization at all. We pulled loan records for arbitrary borrowers by ID, then chained that into the mass-assignment write above to flip our own account to a privileged role. The documented endpoints were never the bug. Testing only what the client hands you is the most common mistake teams make, and it is exactly how an API earns a clean scanner report while being wide open.
For the full sequence end to end, work through the API penetration testing checklist and the tools we reach for at each phase.
Scanners miss the worst API bugs because every one of them depends on knowing who should have access, and a DAST tool has no model of tenancy, ownership, or business state. It fires payloads and grades responses; it will not invent a second account to compare against, so it cannot see that a 200 returned someone else's record. BOLA, BFLA, and broken business logic all require a tester who knows what a correct authorization decision looks like. The same blind spot explains the false negatives that matter most: a scanner reports a clean run not because the API is safe, but because every malicious request it sent came back 200 and it had no second identity to prove the response was wrong.
The fixes are specific and live in configuration, not in a security-awareness slide:
alg against a fixed allowlist (for example ["RS256"]) and reject none outright. Never let the token choose its own algorithm.role, tenantId, and verified should never be client-settable.X-Forwarded-For cannot bypass it.Testing the contract directly with multiple authenticated roles is the only reliable way to catch these before they become a disclosure event. Running it on every deploy instead of once a year is where agentic pentesting changes the economics.