Safeguard
Application Security

Your API Inventory Is Smaller Than Your API

The spec says one number, the gateway serves a larger one, and the difference is your unprotected surface: undecommissioned v1 routes, framework-generated handlers, and the debug endpoint added during an incident.

Aman Khan
AppSec Engineer
6 min read

Ask an engineering team how many API endpoints they expose and you will get a number from the OpenAPI specification. Compare it to what the gateway actually serves and the second number is larger, often considerably.

The difference is your unprotected surface. It contains the v1 endpoints that were never decommissioned when v2 shipped, the internal routes exposed because it was easier than configuring the ingress, the debug handler somebody added during an incident, and everything a framework generates automatically.

This post is how to build an inventory that matches reality, and why the specification does not. For whoever owns API security and has been handed a spec file as the answer.

Why the spec is not the inventory

It is written, not derived. An endpoint exists because code defines a route. It appears in the spec because somebody documented it. Those are different events, and the second one is optional.

Frameworks generate routes nobody wrote. Actuator and management endpoints, health and metrics handlers, admin interfaces from a library, HEAD and OPTIONS variants. None of these appear in a hand-maintained spec, and several of them are interesting to an attacker.

Old versions outlive their documentation. /v1 gets removed from the docs the day /v2 ships and keeps serving traffic for years, because turning it off requires knowing who still calls it and nobody has that list.

Non-production environments are forgotten. Staging, demo and preview deployments frequently expose the same API with weaker controls and real data.

So a spec is a statement of intent. An inventory is a statement of fact, and you need the second one.

Four sources, and the union

No single source is complete. Combine them and reconcile the differences, because the differences are the findings.

The code. Enumerate route definitions directly. This is the most complete source for what the application can serve:

# Spring
grep -rEn '@(Get|Post|Put|Delete|Patch|Request)Mapping' --include=*.java src/

# Express
grep -rEn "\b(app|router)\.(get|post|put|delete|patch)\(" --include=*.js --include=*.ts src/

# Django
grep -rEn "path\(|re_path\(" --include=urls.py .

Crude, and it finds things the spec does not. Run it per service and count.

The gateway or load balancer. What actually gets routed, including anything mapped without going through the application team.

Traffic. Access logs over a week or two, aggregated by path pattern. This is the only source that tells you what is genuinely being used, which is what you need to decommission anything safely. It will also show you endpoints that exist in no other list.

The runtime. Some frameworks expose their own route table. It is authoritative for what is loaded, which is the closest thing to ground truth:

curl -s localhost:8080/actuator/mappings | jq '.contexts[].mappings'

That endpoint being reachable is itself worth checking, since it is a route inventory served to whoever can reach it.

What to record per endpoint

An inventory of paths is not enough to act on. For each one:

  • Authentication required, and which mechanism. The finding is any endpoint answering without one that should not.
  • Authorization model: which roles, and whether object-level ownership is checked.
  • Data classification of what it returns. Endpoints returning personal data are a different tier.
  • Owner, a team. An endpoint with no owner is the one that never gets fixed.
  • Version and deprecation status, with a removal date if deprecated.
  • Rate limit, if any.
  • Exposure: public, internal, or partner.

This is what turns a list into something you can prioritise from. The rows with no owner and no authentication are your working list, and there are usually more of them than anyone expects.

The checks worth running against the union

Endpoints in code but not in the spec. Undocumented surface. Either document it or remove it.

Endpoints in the spec but not in code. Usually harmless, and occasionally it means a consumer is calling something that no longer exists, which will be a support ticket.

Endpoints receiving traffic but marked deprecated. Your removal plan is wrong, and now you know who to talk to.

Endpoints receiving no traffic for ninety days. Decommission candidates. This is how you actually retire /v1, rather than guessing.

Endpoints answering unauthenticated. Test it directly rather than reading the configuration:

while IFS= read -r path; do
  code=$(curl -s -o /dev/null -w "%{http_code}" -m 10 "https://api.example.com$path")
  [ "$code" != "401" ] && [ "$code" != "403" ] && echo "$code $path"
done < endpoints.txt

Anything returning 200 in that list needs an explanation. Some will be legitimately public. The rest are the point of the exercise.

Keep it current without a project

An inventory assembled by hand is accurate on the day it is written.

Generate it in CI, from the code, on every build. Store it as an artifact alongside the release, so you also get a history and can diff between versions. A pull request that adds a route then visibly adds a row, and the reviewer sees it.

Then the useful alert is the diff: a new endpoint appearing in the build that nobody declared, or an endpoint appearing in traffic logs that is not in the generated inventory. The second one catches routes added outside the normal path, which are the ones worth knowing about.

The concession

For a small service with twelve endpoints and one team, this is over-engineering. Read the router file. The spec and reality are the same thing because one person holds both in their head, and formalising it buys nothing.

The threshold is roughly when no single person can enumerate the endpoints from memory, or when more than one team can add a route. Past that point the gap between the documented and the actual surface starts growing on its own, and nobody notices because nothing fails.

The implication

Every other API control you have, authentication, rate limiting, logging, scanning, applies to the endpoints you know about. The ones missing from your list are missing from all of them simultaneously.

That makes the inventory the control that the other controls depend on, which is an argument for deriving it from the code on every build rather than maintaining it as a document.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.