API Security · beginner · ~10 min
**What you will learn** - Explain why an API is a *different* testing target than a graphical web UI, and what that changes for both attackers and defenders. - Map (enumerate) the endpoints, HTTP methods, parameters, and versions an API exposes, using client traffic, OpenAPI/Swagger specs, and Postman collections. - Name the OWASP API Security Top 10 risk classes at a high level and recognise which ones (BOLA/BFLA) dominate real findings. - Perform lab-safe endpoint discovery against a local, intentionally-vulnerable target and record a clean endpoint inventory. - Apply the defender's view: inventory your own API, enforce a documented contract, and log discovery/enumeration behaviour so abuse is detectable. - Avoid the classic misconceptions (a hidden endpoint is not a secure endpoint; a scanner finding nothing does not prove safety).
Security objective. The asset you are protecting is the data and business logic behind an API — user records, orders, admin actions — reachable directly over HTTP. The threat is an attacker who bypasses the official app and talks to the API by hand, calling endpoints the UI never exposes. In this lesson you learn to detect and inventory the full attack surface (every endpoint, method, parameter, and version) so that later lessons can test access control on each one. You cannot secure — or attack in an authorized test — what you have not mapped.
An API (Application Programming Interface) is the backend service that a web page, mobile app, or another program talks to. It speaks HTTP and usually exchanges JSON. This lesson builds directly on your two prerequisites:
GET /users/42 names a resource. Here we treat that request line as an attack surface coordinate: each unique method+path is a door that must be individually checked.Why an API is not just "a website without pictures":
/orders/1001). Change the number and you may reach someone else's object — the root of most API bugs./v1/) frequently stay live next to /v2/ and can carry weaker checks.APIs even have their own risk list — the OWASP API Security Top 10 — separate from the classic web Top 10. The map you build in this lesson is the prerequisite for every test that follows.
Most data and business logic now live behind APIs, so in real authorized engagements the API is the engagement. A few reasons this matters professionally:
The habit this lesson builds — inventory before you test — is exactly what a professional pentester does on day one of an API assessment, and exactly what a security engineer maintains as a living document.
Each concept below is taught on its own: a short definition, a plain explanation, how it works, when it applies (and when it does not), and a pitfall.
Definition. The attack surface is the complete set of reachable method+path combinations, their parameters, their accepted content types, and their versions.
Plain explanation. Think of the API as a building with many doors. The official app only ever opens a few. The attack surface is every door, including the ones no button opens.
How it works. Each unique combination — GET /v2/orders/{id}, POST /v2/orders, DELETE /internal/users/{id} — is a separate unit of behaviour with its own access-control decision. Enumeration produces the list of these units.
When / when-not. Always enumerate before testing individual endpoints. Do not assume the client's traffic shows the full surface — it almost never does.
Pitfall. Treating an endpoint as safe because the UI does not link to it. Obscurity is not authorization; an unlinked DELETE is still callable.
Definition. APIs commonly identify a record by a value in the path or query (/users/42, ?account=1001).
Plain explanation. If the server fetches the object named by the ID without checking that the caller owns it, changing the ID reaches other users' data. That is Broken Object Level Authorization (BOLA). The sibling flaw, Broken Function Level Authorization (BFLA), is calling an action (like an admin function) you should not be allowed to call.
How it works. The insecure assumption is "the app would only ever send my ID." An attacker crafting requests by hand ignores that assumption.
When / when-not. This risk applies to nearly every ID-addressed endpoint. It does not magically disappear because IDs are UUIDs — unguessable is not the same as unauthorized.
Pitfall. Relying on the ID being hard to guess (random UUIDs) instead of checking ownership on every request.
Definition. The tendency of an API to assume its caller is the genuine, well-behaved official client.
Plain explanation. Validation and access checks that "the app already does" are frequently missing on the server. Since anyone can craft a request, server-side checks are the only ones that count.
Pitfall. Hiding a field in the mobile app and assuming users cannot set it. They can — the app is just one possible client.
Definition. Multiple live versions and undocumented/deprecated endpoints existing at once.
Plain explanation. /v1/ may still answer after /v2/ shipped, and often with the security fixes only applied to /v2/. Shadow APIs are undocumented; zombie APIs are deprecated but still reachable.
Pitfall. Documenting and hardening /v2/ while leaving /v1/ running with the old, weaker checks.
Definition. The inputs you combine to build the endpoint map.
Plain explanation. In an authorized test you gather the surface from: (a) the client's JavaScript and mobile-app traffic captured through your proxy; (b) machine-readable OpenAPI/Swagger specs and Postman collections, which teams often leave exposed; and (c) fuzzing paths, parameters, methods, and versions against a target you are permitted to test.
When / when-not. Prefer the documented sources first (traffic, specs) — they are lower-noise and lower-risk. Fuzz only within scope and rate limits.
Pitfall. Aggressive path fuzzing against production; it is noisy, can cause load, and may be out of scope.
THREAT MODEL — API attack surface (authorized lab)
TRUST BOUNDARY (network edge)
|
Untrusted side | Trusted side (your responsibility)
------------------ | ------------------------------------------
|
[Official app] ---- | ---> ( /v2/orders ) --> [Orders service] -> [DB]
[Mobile client] --- | ---> ( /v2/users ) --> [User service] -> [DB]
|
[Hand-crafted ] | ---> ( /v1/users ) <-- ZOMBIE: still live,
[ requests via ] | ( /internal/... ) weaker checks,
[ proxy — ENTRY ] | ---> ( undocumented ) <-- SHADOW: no auth
[ POINT ] |
|
Assets protected: user records, orders, admin actions (behind the API)
Entry points: every reachable method+path, ESPECIALLY undocumented ones
Insecure assumption: "only the official app calls us"
Knowledge check
/v1/users succeed when /v2/users is patched?The core "syntax" here is the HTTP request line plus the shape of an OpenAPI document — the two things you read and write while enumerating.
A request line names one unit of attack surface:
GET /v2/orders/1001 HTTP/1.1 # method path(with ID) version
Host: localhost:8080 # which service (lab host only)
Authorization: Bearer <token> # who the server THINKS you are
GET / POST / PUT / DELETE — trying other methods on a known path often reveals hidden behaviour./v2/... — swap v2→v1 to probe version sprawl.1001 — the object identifier; the coordinate BOLA testing later manipulates.An OpenAPI/Swagger spec lists the surface for you (excerpt):
paths:
/orders/{id}: # one endpoint...
get: # ...and each method on it is a separate door
parameters:
- name: id
in: path
required: true
security:
- bearerAuth: [] # documented auth requirement — verify it is ENFORCED
Reading a spec tells you what should exist and what auth is claimed. It does not prove the server enforces it — that is what you verify.
Modern applications are mostly APIs with a thin client layer on top. The API is where the real logic — and the real attack surface — lives.
/v1/ and /v2/, plus deprecated-but-live endpoints, can carry weaker checks.APIs have their own risk list, distinct from the web Top 10. The standouts (covered next) are:
The goal is a complete map: every endpoint, its parameters, and the objects each one touches. Then you test authorization on every one.
To build that map:
ffuf), and try other HTTP methods and versions.The example below is a small, self-contained lab in three parts: an intentionally-vulnerable API surface, the secure version, and checks that prove the fix rejects bad input and accepts good input. It uses Python's standard library only (no external packages, no real hosts), so it runs entirely on localhost.
# vuln_api.py -- runs on 127.0.0.1 only. Educational lab target.
import json
from http.server import BaseHTTPRequestHandler, HTTPServer
# Fake data store. IDs are sequential -> easy to enumerate.
ORDERS = {
"1001": {"owner": "alice", "total": 30},
"1002": {"owner": "bob", "total": 55},
}
class VulnHandler(BaseHTTPRequestHandler):
def do_GET(self):
# INSECURE: fetches the object named by the ID with NO ownership check.
# Also: /v1/ is a live "zombie" that skips auth entirely.
if self.path.startswith("/v1/orders/") or self.path.startswith("/v2/orders/"):
oid = self.path.rsplit("/", 1)[-1]
order = ORDERS.get(oid)
if order is None:
return self._send(404, {"error": "not found"})
return self._send(200, order) # anyone can read any order
return self._send(404, {"error": "not found"})
def _send(self, code, obj):
body = json.dumps(obj).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(body)
def log_message(self, *a): # silence default logging for the demo
pass
if __name__ == "__main__":
HTTPServer(("127.0.0.1", 8080), VulnHandler).serve_forever()
The surface here has two problems an enumeration pass would surface: a live /v1/ zombie, and no ownership check on the object ID.
# secure_api.py -- runs on 127.0.0.1 only.
import json, logging, uuid
from http.server import BaseHTTPRequestHandler, HTTPServer
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(message)s",
)
log = logging.getLogger("api")
ORDERS = {
"1001": {"owner": "alice", "total": 30},
"1002": {"owner": "bob", "total": 55},
}
# Map bearer token -> user. In real systems a token is verified, not looked up like this.
TOKENS = {"alice-token": "alice", "bob-token": "bob"}
class SecureHandler(BaseHTTPRequestHandler):
def do_GET(self):
cid = uuid.uuid4().hex[:8] # correlation id for the log trail
src = self.client_address[0]
# 1) Retire the zombie: /v1/ is gone.
if self.path.startswith("/v1/"):
log.info("cid=%s src=%s path=%s decision=DENY reason=retired_version",
cid, src, self.path)
return self._send(404, {"error": "not found"})
if self.path.startswith("/v2/orders/"):
oid = self.path.rsplit("/", 1)[-1]
# 2) Authenticate the caller.
user = TOKENS.get(self._bearer())
if user is None:
log.info("cid=%s src=%s path=%s decision=DENY reason=no_auth",
cid, src, self.path)
return self._send(401, {"error": "unauthorized"})
order = ORDERS.get(oid)
# 3) Authorize: caller must OWN the object (fixes BOLA).
if order is None or order["owner"] != user:
# Same response whether missing or forbidden -> no ID oracle.
log.info("cid=%s src=%s user=%s oid=%s decision=DENY reason=not_owner",
cid, src, user, oid)
return self._send(404, {"error": "not found"})
log.info("cid=%s src=%s user=%s oid=%s decision=ALLOW",
cid, src, user, oid)
return self._send(200, order)
log.info("cid=%s src=%s path=%s decision=DENY reason=unknown_route",
cid, src, self.path)
return self._send(404, {"error": "not found"})
def _bearer(self):
h = self.headers.get("Authorization", "")
return h[7:] if h.startswith("Bearer ") else ""
def _send(self, code, obj):
body = json.dumps(obj).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(body)
def log_message(self, *a):
pass
if __name__ == "__main__":
HTTPServer(("127.0.0.1", 8080), SecureHandler).serve_forever()
# verify.py -- run against secure_api.py on 127.0.0.1:8080
import json, urllib.request, urllib.error
def get(path, token=None):
req = urllib.request.Request("http://127.0.0.1:8080" + path)
if token:
req.add_header("Authorization", "Bearer " + token)
try:
with urllib.request.urlopen(req) as r:
return r.status, json.loads(r.read())
except urllib.error.HTTPError as e:
return e.code, None
cases = [
# (description, path, token, expected_status)
("owner reads own order -> ACCEPT", "/v2/orders/1001", "alice-token", 200),
("cross-user read (BOLA) -> REJECT", "/v2/orders/1002", "alice-token", 404),
("no token -> REJECT", "/v2/orders/1001", None, 401),
("zombie /v1 route -> REJECT", "/v1/orders/1001", "alice-token", 404),
]
for desc, path, token, expected in cases:
status, _ = get(path, token)
ok = "PASS" if status == expected else "FAIL"
print(f"[{ok}] {desc} (got {status}, wanted {expected})")
How to run and what to expect. In one terminal: python3 secure_api.py. In another: python3 verify.py. Each line should print [PASS]. The owner reading their own order returns 200; the cross-user (BOLA) attempt, the unauthenticated attempt, and the retired /v1/ route are all rejected. Point verify.py at vuln_api.py instead and the BOLA and zombie cases flip to FAIL — visible proof the vulnerable version leaks. Nothing here touches a network beyond loopback.
Walking the SECURE do_GET handler, which is where the interesting decisions happen.
cid = uuid.uuid4().hex[:8] — mint a short correlation id so every log line for this one request can be tied together during an investigation.src = self.client_address[0] — capture the source (in the lab, always loopback). In production this is the field that lets you spot one source enumerating many IDs.if self.path.startswith("/v1/") — the zombie killer. The retired version returns 404 and logs reason=retired_version. Version sprawl is closed and observable.oid = self.path.rsplit("/", 1)[-1] — pull the object identifier out of the path. This is the exact coordinate a BOLA attacker would iterate.user = TOKENS.get(self._bearer()) — authenticate (who are you?). No/unknown token → 401 and reason=no_auth.if order is None or order["owner"] != user — authorize (are you allowed this object?). This single line is the BOLA fix: ownership is checked on every request rather than assumed.404 for both "missing" and "not yours" — deliberately identical, so an attacker cannot use the status code as an oracle to learn which IDs exist.log.info(... decision=ALLOW ...) — only successful, authorized reads reach here and are logged as allowed.| Request | Token → user | Object owner | Check result | Status | Log reason |
|---|---|---|---|---|---|
GET /v2/orders/1001 |
alice |
alice |
owner matches | 200 |
ALLOW |
GET /v2/orders/1002 |
alice |
bob |
owner mismatch | 404 |
not_owner |
GET /v2/orders/1001 |
none | — | no auth | 401 |
no_auth |
GET /v1/orders/1001 |
alice |
— | retired route | 404 |
retired_version |
Notice how the two DENY-with-404 rows differ only in the log reason, not the response — the difference is visible to the defender, not the attacker.
Real mistakes learners and teams make while mapping and hardening an API surface.
| Wrong approach | Why it is wrong | Corrected approach | How to recognise / prevent |
|---|---|---|---|
| "The UI never shows this endpoint, so it is safe." | Obscurity is not access control; any client can call an unlinked path. | Enforce authentication + ownership on the server for every endpoint. | Enumerate your own API and confirm each route has an explicit auth check. |
Hardening /v2/ and forgetting /v1/ is still live. |
Version sprawl leaves the old, weaker checks reachable. | Retire deprecated versions (return 404/410) or apply identical checks. | Add a test that asserts retired versions are unreachable. |
| Trusting a Swagger spec as proof the API is secure. | A spec lists claimed behaviour; the server may not enforce it. | Verify enforcement with requests, not documentation. | For each security: block in the spec, send an unauthenticated request and confirm it is rejected. |
| Assuming random UUID IDs prevent BOLA. | Unguessable is not unauthorized; IDs leak via other endpoints, referers, logs. | Check ownership on every object access regardless of ID format. | Attempt a cross-user read in the lab; it must return 404/403. |
| Path-fuzzing production to "see what's there." | Noisy, can degrade service, and is often out of scope/illegal. | Fuzz only in-scope, rate-limited, ideally a staging/lab copy. | Confirm written authorization and scope before any fuzzing. |
| Different responses for "not found" vs "forbidden." | The status difference becomes an oracle that enumerates valid IDs. | Return an identical response for both; distinguish only in logs. | Compare responses for a real-but-foreign ID and a nonexistent ID. |
When enumeration or your verification checks behave unexpectedly, work through these.
verify.py cannot connect (ConnectionRefusedError). The server is not running or is on a different port. Confirm secure_api.py is running and bound to 127.0.0.1:8080; check nothing else holds the port with lsof -i :8080./v2/order/ vs /v2/orders/). Print self.path on entry to do_GET and compare exactly.secure_api.py, not vuln_api.py, and that order["owner"] != user is actually reached (log user and oid)._bearer() is matching an empty string to a real user, or TOKENS contains an empty key. Log the parsed token value.Questions to ask when a request fails during enumeration: Which exact method+path+version did I send? What status and body came back? Does the response differ from a known-good request in a way that leaks information? Is this failure the server denying me (a good sign of a control) or my tooling misfiring? Am I still inside the authorized scope?
Security & safety — detection and logging for API enumeration
Mapping is silent unless you make it observable. Good logging turns enumeration into a detectable event and gives incident responders a trail.
What to log on every request (see the secure example):
logging's %(asctime)s).ALLOW / DENY) plus a machine-readable reason.What to NEVER log: passwords, bearer/session tokens, session cookies, API keys, private keys, full payment card numbers (PANs), or unneeded PII. Log a token reference or a hash prefix if you must correlate, never the secret itself.
Events that signal enumeration abuse:
404s across never-before-seen paths (path fuzzing)./v1/) or /internal/-style routes.PUT, DELETE, OPTIONS) on endpoints the UI only GETs.How false positives arise: a new legitimate integration, a mobile app rollout, a monitoring/health-check bot, or a QA test run can all look like enumeration. That is exactly why you log a decision and a reason rather than only "suspicious" — analysts need the context to separate a new partner integration from an attacker, and an allow-list of known clients cuts the noise.
Authorized real-world use case. A company hires a pentester to assess its mobile banking API. Before testing a single control, the tester produces an endpoint inventory: they route the app through an intercepting proxy on a test account, pull the exposed OpenAPI spec, import the team's Postman collection, and lightly fuzz for version sprawl within the agreed scope. The deliverable — a table of every method+path+version and its claimed auth — is what makes the rest of the engagement (BOLA/BFLA testing) systematic instead of guesswork. On the defensive side, the platform team maintains that same inventory as a living document and wires alerts for traffic to retired versions.
Professional best-practice habits
| Habit | Beginner | Advanced |
|---|---|---|
| Inventory | Build an endpoint list from proxy traffic + any spec you can find. | Continuous, automated API discovery in CI; diff each build to catch shadow endpoints before release. |
| Validation | Check auth manually on each endpoint you found. | Contract tests assert every route enforces its documented security; unauthenticated calls fail the build. |
| Least privilege | Test that a normal user cannot reach admin functions. | Per-route authorization matrix reviewed on every change; default-deny routing. |
| Secure defaults | Retire old versions in the lab and confirm 404. | Deprecation policy with hard sunset dates; zombie routes fail health checks. |
| Logging | Log method/path/version/decision for each request. | Correlated, alertable logs feeding a SIEM with enumeration-pattern detections. |
| Ethics | Only touch localhost/lab targets. | Documented scope, rate limits, and rules of engagement for every engagement. |
Authorization checklist (before any lab or engagement): (1) written permission covering these exact hosts and this window; (2) explicit scope — which paths/versions are in and out; (3) rate limits and a stop condition agreed; (4) a rollback/contact plan if something breaks; (5) all targets are systems you own or are explicitly authorized to test (localhost, containers, intentionally-vulnerable VMs, or a CTF).
All tasks are lab-only: run everything against 127.0.0.1 / the provided vuln_api.py / secure_api.py, never a system you do not own or are not authorized to test. Each security task ends by remediating and verifying.
Beginner 1 — Build an endpoint inventory.
vuln_api.py; using your proxy or curl, record each endpoint you can reach and the status it returns./v1/orders/1001 and /v2/orders/1001; try an ID that does not exist.Beginner 2 — Spot the zombie.
/v1/ is a live zombie route in the vulnerable server./v1/... and /v2/...; note that both answer./v1/ and how you would verify it is gone.Intermediate 1 — Demonstrate and then fix a BOLA read.
vuln_api.py, read order 1002; against secure_api.py, repeat with alice-token and observe rejection.GET /v2/orders/1002; vulnerable → 200 + bob's data, secure → 404.Intermediate 2 — Write a detection rule from the logs.
verify.py (and a few extra cross-user reads) against secure_api.py, then read the logs.reason=not_owner denials within M seconds") plus one plausible false positive.oid values from one src with repeated DENY reasons is the signal.Challenge — Contract-verify the whole surface.
(method, path) in a hand-written list, sends an unauthenticated request to secure_api.py and asserts it is rejected (401/404), and a documented zombie route is unreachable.verify.py; the point is that the spec's claims are tested against actual behaviour.