API Security · beginner · ~9 min
**What you will learn** - Locate an API's documentation sources: OpenAPI/Swagger specs, Postman collections, and GraphQL introspection. - Go beyond the docs to find hidden endpoints from client JavaScript, traffic capture, path/parameter fuzzing, and version sprawl. - Turn a raw endpoint list into a structured test map (parameters, objects, expected role) that feeds the BOLA, BFLA, and mass-assignment tests. - Recognise that an exposed production spec or enabled introspection is a finding in its own right, and explain *why*. - Apply a lab-only, authorized workflow with logging, cleanup, and a defensive remediation for each finding.
Security objective. The asset you are protecting is the complete attack surface of an API — every endpoint, parameter, object type, and privileged function. The threat is an attacker who finds an endpoint your team forgot existed (an old version, an internal admin route, a debug handler) and attacks it because nobody tested it. In this lesson you learn to detect that surface yourself, the way a defender or an authorized tester would, so nothing is left in the dark.
What discovery is. Discovery is the disciplined work of mapping an API completely. An API is opaque from the outside: you see only the responses to requests you already know how to make. Discovery converts that opaque service into a written, testable map.
Where the map comes from. Several sources describe an API and are frequently left reachable:
Documentation rarely covers everything, so the rest of the map comes from reading client-side JavaScript, capturing app traffic, fuzzing paths and parameters, probing old versions, and trying undocumented HTTP methods.
How it connects to the prereq. In The API attack surface you learned to think of an API as a collection of entry points and trust boundaries. Discovery is the practical step that enumerates that surface: it produces the concrete list of entry points that later authorization and input-validation tests iterate over. Without a complete map, every test you run is incomplete by definition.
In authorized professional work — a scoped API penetration test, a bug-bounty engagement within its rules, or an internal security review — you can only test the endpoints you can see. Any route you miss is untested attack surface. If a real attacker finds it first, the consequences land on real users.
Three concrete reasons discovery matters:
/v0 and /v1 endpoints, debug handlers, and internal admin routes often skip the security controls added later to the main app. They are disproportionately vulnerable.Doing discovery well early also makes every downstream test faster and more thorough, because the parameters and schemas you collect tell you exactly what to try.
Definition. Machine-readable or shared descriptions of an API's routes, parameters, and data shapes.
Plain explanation. Teams generate docs to help their own developers. Those same docs, if reachable by outsiders, are a gift to an attacker: a labelled map instead of a blind maze.
How it works.
| Source | Where it lives | What it reveals |
|---|---|---|
| OpenAPI/Swagger | /swagger.json, /openapi.json, /swagger-ui, /v3/api-docs |
Every documented endpoint, method, parameter, schema |
| Postman collection | Shared workspace link, leaked .postman_collection.json |
Real example requests, sometimes with tokens baked in |
| GraphQL introspection | The GraphQL endpoint (often /graphql) via an introspection query |
The entire type system: queries, mutations, fields |
When / when-not. Always check for these first in an authorized test — they are the cheapest, highest-value source. Do not assume they are complete; docs describe the intended API, not the actual one.
Pitfall. Reading a spec is not the same as confirming the endpoints exist and behave as described. Always verify by making the request in your lab.
Definition. Finding endpoints that no published document lists.
Plain explanation. The app itself is honest about which routes it calls. Its JavaScript, its network traffic, and its error behaviour leak the true endpoint list.
How it works.
/v2 for /v1 or /v0; older versions may lack newer checks.PUT, DELETE, PATCH, OPTIONS on a path you already know from a GET.When / when-not. Do this only inside your authorization scope. Fuzzing generates load and noise, so respect rate limits and the rules of engagement.
Pitfall. Fuzzing produces false positives: a server that returns 200 for everything, or soft-404 pages, will make dead paths look real. Confirm each hit manually.
Definition. Structuring each discovered endpoint so it can drive a specific security test.
Plain explanation. A flat list of URLs is not yet useful. For each endpoint, record what it takes in, what it acts on, and who is supposed to call it.
How it works. For every endpoint note three things: its parameters, the objects it touches (e.g. an order id, a user id), and the role it expects (anonymous, user, admin). That triple is exactly what the BOLA (object-level authorization), BFLA (function-level authorization), and mass-assignment tests consume. The schema is a bonus here: field names in the schema are the candidates you try in mass-assignment.
When / when-not. Do this continuously as you discover, not at the end — the structure guides where to dig next.
Pitfall. Skipping the role column. Without knowing the expected caller, you cannot tell an authorization bug from correct behaviour.
Definition. The presence of the discovery surface itself being a vulnerability.
Plain explanation. Docs are fine on an internal developer portal. Reachable from the public internet on production, they lower the cost of every other attack.
How it works. Exposed production Swagger UI, enabled GraphQL introspection, and live deprecated versions each get written up as findings — with severity based on what they expose and how reachable they are, not automatically "critical."
Threat model
TRUST BOUNDARY (public internet)
ATTACKER / TESTER | | SERVER SIDE
(unauthenticated) | |
| |
browser / curl ----+--> [ /swagger-ui /openapi.json ]---> full endpoint map
| ENTRY POINT: docs routes |
| |
fuzzer -----------+--> [ /v1 /v0 /debug /admin ]---> hidden/old routes
| ENTRY POINT: undocumented | (weaker checks)
| |
GraphQL query ----+--> [ /graphql (introspection) ]---> entire schema
| ENTRY POINT: introspection |
| |
ASSET PROTECTED: the complete, minimal attack surface
(only intended endpoints reachable; docs + introspection restricted;
deprecated versions retired)
Knowledge check
404s from one source (fuzzing), hits on /swagger* or /openapi*, and introspection queries on the GraphQL endpoint.The core building blocks are HTTP requests you already know, plus one GraphQL query. All examples target a local lab (localhost).
# Fetch an OpenAPI/Swagger spec (lab host only)
GET /openapi.json HTTP/1.1
Host: localhost:8000
# A 200 with JSON that has an "openapi" or "swagger" key = spec exposed.
# Minimal GraphQL introspection probe: does introspection work at all?
# Send as the "query" field of a POST to the GraphQL endpoint.
query { __schema { queryType { name } } }
# A non-null __schema in the response = introspection is ENABLED (a finding).
# Directory/endpoint fuzzing with ffuf against a LOCAL lab only.
# FUZZ is replaced by each word in the list; -mc 200,401,403 shows
# "exists but maybe protected" responses and hides plain 404s.
ffuf -u http://localhost:8000/FUZZ -w wordlist.txt -mc 200,401,403
Note the interpretation rules: a 401/403 is still a discovery (the route exists, it just needs auth); a 200 on a nonsense path warns of a soft-404 you must rule out.
You can't test what you can't see. Discovery turns an opaque API into a complete, testable map.
/swagger.json, /openapi.json, /swagger-ui) describe every endpoint, parameter, and schema. They are ideal for testing, and often left publicly exposed.Docs rarely list everything. Also do the following:
ffuf) and parameters./v1 to /v2, or a deprecated /v0), which may have weaker checks.For every endpoint you discover, note three things:
Then run the authorization tests (BOLA/BFLA), mass-assignment checks, and rate-limit checks from this track.
The schema is also useful here: it reveals field names to try in mass-assignment.
The following are findings in their own right, because they hand attackers the map:
The workflow below is INSECURE server → SECURE server → VERIFY. It runs entirely on localhost against a tiny lab API you control. Nothing here targets a third party.
Authorization checklist (before running anything):
localhost) or an isolated container.This lab API leaves its OpenAPI spec public and keeps a deprecated /v0 route with no auth.
# vuln_api.py -- LAB ONLY. Do not deploy.
from flask import Flask, jsonify, request
app = Flask(__name__)
# INSECURE: spec served publicly in production-like mode
@app.get("/openapi.json")
def spec():
return jsonify({
"openapi": "3.0.0",
"paths": {
"/v1/orders/{id}": {"get": {}},
"/v0/orders/{id}": {"get": {}}, # deprecated, still live
},
})
# INSECURE: deprecated v0 with NO auth check
@app.get("/v0/orders/<int:id>")
def v0_order(id):
return jsonify({"id": id, "total": 42, "note": "legacy, no auth"})
# The intended, current route DOES check a token
@app.get("/v1/orders/<int:id>")
def v1_order(id):
if request.headers.get("Authorization") != "Bearer lab-token":
return jsonify({"error": "unauthorized"}), 401
return jsonify({"id": id, "total": 42})
if __name__ == "__main__":
app.run(host="127.0.0.1", port=8000)
A tester fetches /openapi.json, sees both /v1 and /v0, and finds that /v0/orders/1 returns data with no token. The exposed spec plus the live deprecated route are the findings.
Restrict the spec to non-production, retire the deprecated route, and (if the older behaviour must stay) enforce the same auth on every version.
# secure_api.py -- LAB ONLY illustration of the fix.
import os
from flask import Flask, jsonify, request
app = Flask(__name__)
IS_PROD = os.environ.get("ENV") == "production"
def require_token():
return request.headers.get("Authorization") == "Bearer lab-token"
# FIX 1: only serve the spec outside production
@app.get("/openapi.json")
def spec():
if IS_PROD:
return jsonify({"error": "not found"}), 404
return jsonify({"openapi": "3.0.0", "paths": {"/v1/orders/{id}": {"get": {}}}})
# FIX 2: deprecated route retired -> 410 Gone, and still auth-gated
@app.get("/v0/orders/<int:id>")
def v0_order(id):
return jsonify({"error": "gone; use /v1"}), 410
# FIX 3: current route enforces auth (unchanged, correct)
@app.get("/v1/orders/<int:id>")
def v1_order(id):
if not require_token():
return jsonify({"error": "unauthorized"}), 401
return jsonify({"id": id, "total": 42})
if __name__ == "__main__":
app.run(host="127.0.0.1", port=8000)
# Run the secure server in production mode for the test:
ENV=production python secure_api.py &
# (a) Spec must be hidden in production -> expect 404
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8000/openapi.json
# expected: 404
# (b) Deprecated route must be gone -> expect 410
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8000/v0/orders/1
# expected: 410
# (c) Current route without a token -> expect 401 (rejects bad input)
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8000/v1/orders/1
# expected: 401
# (d) Current route WITH the token -> expect 200 (accepts good input)
curl -s -o /dev/null -w "%{http_code}\n" \
-H "Authorization: Bearer lab-token" http://127.0.0.1:8000/v1/orders/1
# expected: 200
Expected output: 404, then 410, then 401, then 200. Together these show the spec is no longer exposed, the old route is retired, and legitimate authorized access still works.
Cleanup / reset: stop the background server (kill %1 or Ctrl-C), unset ENV (unset ENV), and delete any wordlists or captured output you created for the exercise.
Walkthrough of the vulnerable-to-verified flow.
| Step | Action | What happens | Why it matters |
|---|---|---|---|
| 1 | GET /openapi.json on the vulnerable server |
Returns JSON listing /v1/orders/{id} and /v0/orders/{id} |
The spec hands over the whole map, including a route not meant to be advertised |
| 2 | Read the map | Two versions of the same resource are visible | Version sprawl spotted — /v0 is the interesting one |
| 3 | GET /v0/orders/1 (no token) |
200 with order data |
Deprecated route skips the auth the current route enforces — a real vulnerability |
| 4 | GET /v1/orders/1 (no token) |
401 |
Confirms the intended control exists, isolating /v0 as the gap |
| 5 | Apply fixes | Spec 404s in prod, /v0 returns 410, /v1 unchanged |
Surface is minimised and every remaining route is auth-gated |
| 6a | Verify spec | curl → 404 |
Map is no longer free to outsiders in production |
| 6b | Verify old route | curl → 410 Gone |
Deprecated surface removed; 410 also tells honest clients to move on |
| 6c | Verify current route, no token | curl → 401 |
The fix rejects unauthenticated access (bad input) |
| 6d | Verify current route, with token | curl → 200 |
The fix still accepts legitimate access (good input) — no over-correction |
The key value change is the response code on the deprecated route: 200 (leaking data) before, 410 (retired) after, while the current route's 401/200 split proves auth is enforced without breaking real users.
Mistake 1 — Trusting the spec as the whole truth.
/openapi.json and calling coverage complete.Mistake 2 — Counting soft-404s as real endpoints.
200 from a fuzzer as a live route.200 (or a friendly HTML page) for missing paths, so nonsense paths look real./zzz-not-real-9182 returns 200, distrust all 200s until you filter by size/content.Mistake 3 — Confusing 'documented' with 'exists' (and 'decoded' with 'verified').
Mistake 4 — Fuzzing out of scope or ignoring rate limits.
Common problems and how to work through them.
/openapi.json, /swagger.json, /swagger-ui, /v3/api-docs, /api-docs. Also check the HTML of the app for a link to the docs, and the JS bundle for a basePath.403/validation error on the introspection query usually means introspection is disabled — note that as good hygiene, not a failure. Confirm you are POSTing to the right GraphQL endpoint and sending the query in the query field.ffuf, -fc 404 won't help here; instead filter by response size with -fs <size> after baselining a known-bad path).curl returns nothing / connection refused. The lab server is not running or is on a different port. Check it is bound to 127.0.0.1:8000 and started.401/403. That is still a discovery — the route exists and is auth-gated. Record it as reachable-but-protected; do not discard it.Questions to ask when discovery stalls:
/v0, /v1, /beta, /internal)?Security & safety — detection and logging.
Discovery is noisy by nature, which is good news for defenders: it is very detectable. Whether you are the tester documenting your own activity or the defender building alerting, log the right things.
What to log for each request/decision:
What to NEVER log:
Authorization header values.Events that signal abuse (or an active discovery attempt):
404s from one source in a short window (path fuzzing)./swagger*, /openapi*, /api-docs, or __schema introspection queries./v0) that legitimate clients no longer use.OPTIONS, PUT, DELETE) probed across many paths.How false positives arise:
404s without malice.Tune alerts on rate and diversity of paths per source, and correlate with authentication state, so a single mistyped URL does not page anyone while a real fuzzing sweep does.
Authorized real-world use case. A company hires a firm for a scoped API penetration test. On day one the testers pull the staging OpenAPI spec, capture the mobile app's traffic, and run throttled path fuzzing within the rules of engagement. The output is a spreadsheet of every endpoint with its parameters, objects, and expected role — the map that the rest of the engagement (authorization, mass-assignment, and rate-limit testing) works through. Two findings come straight from discovery: a production Swagger UI reachable without auth, and a live /v0 that skips the newer authorization checks.
Best-practice habits.
| Habit | Beginner | Advanced |
|---|---|---|
| Validation | Confirm each discovered route by requesting it in the lab | Baseline soft-404s and filter fuzzer output by size/content |
| Least privilege | Note the expected role for every endpoint | Cross-check that each version enforces the same role as the current one |
| Secure defaults | Keep specs out of production; disable introspection there | Serve docs only behind auth on an internal network; gate by environment flag |
| Logging | Record every request you send during testing | Build detections for docs hits, introspection, and 404 bursts |
| Error handling | Treat 401/403 as "exists but protected," not "nothing" |
Retire deprecated routes with 410 Gone and monitor residual traffic |
Defensive framing. For each discovery finding, the deliverable is remediation plus verification: restrict or remove the exposure, then re-test to confirm the map is no longer free and legitimate access still works. Never claim the API is "completely secure" — claim that the tested surface behaved correctly on retest.
All tasks are lab-only: run them against a service you own on localhost or in an isolated container. Do not point any tool at a system you are not authorized to test.
Beginner 1 — Find the spec.
/openapi.json, /swagger.json, /swagger-ui, /v3/api-docs).openapi or swagger key confirms it.Beginner 2 — Build the map triple.
parameters and requestBody schema in the spec give you the fields.Intermediate 1 — Scoped path fuzzing with a baseline.
ffuf against your lab.401/403 as valid discoveries.Intermediate 2 — GraphQL introspection check.
{ __schema { queryType { name } } } probe.__schema means enabled; an error usually means disabled.Challenge — Full discovery report on the lab.
/openapi.json, /swagger-ui), Postman collections, and GraphQL introspection ({ __schema { queryType { name } } }).ffuf fuzzing, version sprawl (/v0, /v1), and undocumented HTTP methods. Baseline soft-404s so dead paths do not masquerade as real ones.410, disable introspection in production, log discovery-shaped traffic (never log secrets), and finish every finding with a remediation and a retest. Run on authorized labs only, and clean up afterwards.