API Security · beginner · ~10 min

The API attack surface

**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).

Overview

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:

  • REST APIs, HTTP methods, and JSON — you already know that a request is a method + path + headers + body, and that 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.
  • Testing the web: intercepting and shaping requests — you already know how to use an intercepting proxy to view and edit traffic. Here you use that same skill to discover which doors exist, not just replay ones the UI clicked.

Why an API is not just "a website without pictures":

  • No UI to guide you. A graphical app only lets you click the buttons it drew. An API has no buttons. Many endpoints exist that the client never calls, and there is no page to "walk through" — you must discover them.
  • Objects exposed by ID. APIs routinely fetch records directly by identifier (/orders/1001). Change the number and you may reach someone else's object — the root of most API bugs.
  • Over-trust of the client. APIs often assume the caller is the official, well-behaved app and under-check hand-crafted requests.
  • Version sprawl. Old versions (/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.

Why it matters

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:

  • Authorization bugs dominate. Because records are reachable by ID and functions by path, broken access control (BOLA/BFLA) is consistently the most common and most damaging API finding. But you can only test access control on endpoints you know exist — which makes enumeration the highest-leverage first step.
  • There is no UI to bound the test. A graphical app quietly limits a casual user to its buttons. That false sense of safety evaporates once someone talks to the API directly. Defenders who only tested "through the app" miss the real surface.
  • Shadow and zombie APIs are everywhere. Undocumented ("shadow") and deprecated-but-live ("zombie") endpoints are a leading cause of breaches. Whether you are attacking (with authorization) or defending, an accurate inventory is the deliverable clients actually pay for.
  • Detection depends on knowing normal. A defender who has enumerated their own API can write alerts for traffic that hits endpoints, methods, or versions that should be retired. Without the map, enumeration attempts look like ordinary noise.

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.

Core concepts

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.

1. The attack surface of an API

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.

2. Object-by-ID access and why authorization bugs dominate

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.

3. Client over-trust

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.

4. Version sprawl (shadow and zombie APIs)

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.

5. Enumeration sources

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

  1. What asset is protected in the diagram above, and where is the trust boundary?
  2. Which insecure assumption lets a hand-crafted request to /v1/users succeed when /v2/users is patched?
  3. Why should the path-fuzzing part of enumeration only ever run in an authorized lab or in-scope engagement?

Syntax notes

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 v2v1 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.

Lesson

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.

Why APIs differ from web UIs

  • No guiding UI. The client only ever calls some of the available endpoints; many more exist. There is no page to "click through," so you must discover endpoints yourself.
  • More objects, direct access. APIs expose objects by ID directly. As a result, authorization bugs (BOLA/BFLA) dominate.
  • Trust in the client. APIs often assume the official app is the caller. They tend to under-check requests that an attacker crafts by hand.
  • Versioning sprawl. Paths like /v1/ and /v2/, plus deprecated-but-live endpoints, can carry weaker checks.

OWASP API Top 10

APIs have their own risk list, distinct from the web Top 10. The standouts (covered next) are:

  • Broken Object Level Authorization (BOLA) — accessing another user's object by changing its ID.
  • Broken Function Level Authorization (BFLA) — calling an action you should not be allowed to call.
  • Mass assignment — setting fields the client should not control.
  • Unrestricted resource consumption — missing rate limiting.

Enumerating an API

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:

  • Read the client's JavaScript and mobile app traffic to learn which endpoints exist.
  • Find Swagger/OpenAPI documentation and Postman collections, which are often exposed.
  • Fuzz paths and parameters (for example, with ffuf), and try other HTTP methods and versions.

Code examples

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.

Part 1 — WARNING: intentionally vulnerable — use only in a local, isolated, authorized lab. Do not deploy.

# 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.

Part 2 — the SECURE fix

# 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()

Part 3 — VERIFY: prove the fix rejects bad input and accepts good input

# 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.

Line by line

Walking the SECURE do_GET handler, which is where the interesting decisions happen.

  1. 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.
  2. 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.
  3. if self.path.startswith("/v1/") — the zombie killer. The retired version returns 404 and logs reason=retired_version. Version sprawl is closed and observable.
  4. oid = self.path.rsplit("/", 1)[-1] — pull the object identifier out of the path. This is the exact coordinate a BOLA attacker would iterate.
  5. user = TOKENS.get(self._bearer())authenticate (who are you?). No/unknown token → 401 and reason=no_auth.
  6. if order is None or order["owner"] != userauthorize (are you allowed this object?). This single line is the BOLA fix: ownership is checked on every request rather than assumed.
  7. Returning 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.
  8. 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.

Common mistakes

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.

Debugging tips

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.
  • Every case returns 404, even the owner read. Your path prefix match is off (e.g. /v2/order/ vs /v2/orders/). Print self.path on entry to do_GET and compare exactly.
  • BOLA case unexpectedly returns 200. The ownership check is not running. Confirm you are testing secure_api.py, not vuln_api.py, and that order["owner"] != user is actually reached (log user and oid).
  • The no-token case returns 200 instead of 401. _bearer() is matching an empty string to a real user, or TOKENS contains an empty key. Log the parsed token value.
  • Fuzzing finds nothing. A wordlist may be too small, or the server rate-limited/blocked you. Confirm scope, slow down, and check the server logs — silence on the client may be denial on the server.

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?

Memory safety

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):

  • Timestamp (from logging's %(asctime)s).
  • Source (client IP; add a stable client/app identifier if you have one).
  • Resource: method, full path, and version — version is what reveals zombie-route probing.
  • Result: HTTP status and the security decision (ALLOW / DENY) plus a machine-readable reason.
  • A correlation id so all lines for one request join up across services.

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:

  • One source hitting many sequential or many distinct object IDs in a short window (BOLA sweeping).
  • A burst of 404s across never-before-seen paths (path fuzzing).
  • Requests to retired versions (/v1/) or /internal/-style routes.
  • Uncommon methods (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.

Real-world uses

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).

Practice tasks

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.

  • Objective: produce a table of every reachable method+path+version on the lab API.
  • Requirements: start vuln_api.py; using your proxy or curl, record each endpoint you can reach and the status it returns.
  • Output: a markdown table with columns method, path, version, status, notes.
  • Constraints: loopback only; no fuzzing beyond the two known versions.
  • Hints: try /v1/orders/1001 and /v2/orders/1001; try an ID that does not exist.
  • Concepts: attack surface, version sprawl.

Beginner 2 — Spot the zombie.

  • Objective: demonstrate that /v1/ is a live zombie route in the vulnerable server.
  • Requirements: send the same request to /v1/... and /v2/...; note that both answer.
  • Output: the two responses side by side plus one sentence on why the zombie is a risk.
  • Constraints: read-only requests; lab only.
  • Hints: compare status and body; the risk is weaker/absent checks on the old version.
  • Concepts: zombie APIs, enumeration sources.
  • Defensive conclusion: state how you would retire /v1/ and how you would verify it is gone.

Intermediate 1 — Demonstrate and then fix a BOLA read.

  • Objective: show the vulnerable server lets one identity read another's order, then confirm the secure server blocks it.
  • Requirements: against vuln_api.py, read order 1002; against secure_api.py, repeat with alice-token and observe rejection.
  • Input/output: GET /v2/orders/1002; vulnerable → 200 + bob's data, secure → 404.
  • Constraints: lab only; do not modify data.
  • Hints: the fix is the ownership check; the identical-404 hides which IDs exist.
  • Concepts: BOLA, object-by-ID access, secure defaults.
  • Defensive conclusion: remediate = enforce ownership on every access; verify = cross-user read returns 404/403.

Intermediate 2 — Write a detection rule from the logs.

  • Objective: define what enumeration looks like in the secure server's log output.
  • Requirements: run verify.py (and a few extra cross-user reads) against secure_api.py, then read the logs.
  • Output: a short rule in plain English (e.g. "alert when one source produces N reason=not_owner denials within M seconds") plus one plausible false positive.
  • Constraints: describe detection only; no blocking logic required.
  • Hints: sequential oid values from one src with repeated DENY reasons is the signal.
  • Concepts: detection & logging, false positives.

Challenge — Contract-verify the whole surface.

  • Objective: turn a tiny OpenAPI-style list into an automated check that every documented route enforces auth.
  • Requirements: write a small script that, for each (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.
  • Output: PASS/FAIL per route, exit non-zero on any FAIL.
  • Constraints: standard library only; loopback only; no destructive methods against real data.
  • Hints: model it on verify.py; the point is that the spec's claims are tested against actual behaviour.
  • Concepts: enumeration → verification, mitigation verification, secure defaults.
  • Defensive conclusion: this script is your regression test — retiring a route or adding a control should keep it green; a new shadow endpoint should make you extend the list.

Summary

  • An API's attack surface is every reachable method+path+parameter+version — far larger than what the UI clicks. You must enumerate it before you can test or defend it.
  • Authorization flaws lead the OWASP API Security Top 10: BOLA (reading another user's object by ID) and BFLA (calling a function you shouldn't) dominate because APIs expose objects by ID and over-trust the client.
  • Enumeration sources: client/mobile traffic through a proxy, OpenAPI/Swagger specs, Postman collections, and in-scope fuzzing. Documented sources first; fuzz only with authorization and rate limits.
  • Version sprawl (shadow/zombie APIs) is a top real-world cause of breaches — retire old versions and verify they are gone.
  • Key misconceptions to reject: an unlinked endpoint is not secure; a Swagger spec is not proof of enforcement; a random UUID does not authorize; a clean scanner run does not prove safety; nothing is ever "completely secure."
  • Defensive core: authenticate then authorize on every request, return identical responses for missing vs forbidden, and log timestamp/source/resource/version/decision/reason with a correlation id — never log secrets or unneeded PII.
  • Always verify a fix: a cross-user read must be rejected, a valid owner read must succeed, and retired routes must 404 — proven with a repeatable test, on lab hosts only.

Practice with these exercises