Shadow APIs: the v1 endpoint nobody remembered to switch off
Every API inventory is a snapshot of what someone remembered to document. The gap between that list and what your servers actually answer is where breaches live, how to find zombie, shadow and orphaned endpoints, and how to stop creating them.
6 min read0 views
Ask any engineering team for a list of their APIs and you will get a document. Ask their load balancer, and you will get a different, longer, more honest one.
The difference between those two lists is not an administrative annoyance. It is the part of your attack surface that nobody owns, nobody patches, nobody monitors and nobody tests, which makes it, per endpoint, the most dangerous part of your estate.
The Optus breach in 2022 was fundamentally this: an internet-reachable customer-data endpoint that, by the reporting, had been serving without authentication for months and traced back to an access-control flaw introduced years earlier. Roughly 10 million people's records. Nobody had to break anything.
The four kinds
Worth separating, because they are found and fixed differently.
Zombie APIs. Old versions still running. /api/v1/ after everyone migrated to /v2. The clients are gone; the process is not. These are the highest-risk category because they are simultaneously forgotten (no patching, no review, no monitoring) and old (built before your current authorisation model, before rate limiting, before structured logging). A v1 endpoint frequently predates the very control you rely on today.
Shadow APIs. Endpoints that exist but were never in the inventory: a debug route, a health check that returns configuration, an internal service accidentally exposed by an ingress rule, an endpoint added for a partner integration two years ago and never removed. Never documented, so never tested.
Orphaned APIs. Services whose owning team dissolved, was reorganised, or left. Running, sometimes business-critical, with nobody who can say what it does or whether it can be turned off. These fail differently: the problem is not finding them, it is that nobody will authorise the change.
Undocumented parameters. Not a whole endpoint. A documented route that also accepts ?debug=true, ?admin=1, ?include=all, ?format=raw, or an X-Forwarded-For it trusts. Easy to miss because the endpoint itself is on every list.
Why they are worse than the endpoints you know about
An endpoint on your inventory gets, at minimum, the controls you apply by default. A shadow endpoint gets none of them, and specifically:
- It predates your authorisation model. If you added tenant scoping in 2024, anything from 2022 is scoped how it was scoped in 2022.
- It is not in your WAF or gateway policy, which is usually configured per known route.
- It is not rate limited, because the limiter was configured for the routes someone listed.
- It is not in your logging pipeline, or worse, it logs somewhere nobody reads, which means exploitation produces evidence that is never seen.
- It is not in scope for testing. Every penetration test scoped from a documentation set inherits that set's blind spots exactly.
That last point is worth saying plainly, from the testing side: a scope derived from the client's API documentation will reproduce the client's own blind spot. The endpoints most likely to be vulnerable are, by definition, the ones missing from the document you were handed. Discovery has to be part of the work, not an assumption behind it.
Finding them
1. Ask the infrastructure, not the people.
Your routing layer knows the truth. Pull the actual route table from your ingress, your API gateway configuration, your reverse proxy config, your service mesh. Then diff it against the documented inventory. This is usually the highest-yield hour available, and it needs no attacker tooling at all.
2. Mine the access logs.
Every path your servers have answered in the last 90 days, grouped and counted:
awk '{print $7}' access.log | cut -d'?' -f1 | sort | uniq -c | sort -rn
Two things jump out. Paths that are not in the inventory, shadow. And paths with low but non-zero traffic, often zombie endpoints still called by one forgotten client, which is exactly the argument someone will use to keep them alive.
3. Read the specifications you already generate.
Swagger/OpenAPI at /swagger.json, /openapi.json, /v2/api-docs, /api-docs, /redoc. If they are reachable in production, an attacker has your inventory. Check whether the generated spec covers more than the published documentation, often the generator sees routes the docs do not.
4. Read the clients.
JavaScript bundles, mobile apps, and their source maps contain every endpoint the client calls, including the ones behind feature flags. Unpack an APK, pull the strings, extract URL patterns from the JS bundle. Mobile-only endpoints are a classic shadow reservoir because they never appear in web traffic.
5. Version fuzzing.
For each known route, try the neighbours: /v1/, /v0/, /v3/, /api/, /internal/, /beta/, /legacy/, /old/, /test/, /debug/. Zombie endpoints are found by asking for them.
6. Certificate transparency and DNS.
CT logs list every hostname anyone has ever issued a certificate for, api-staging., api-old., internal-api. Passive DNS and subdomain enumeration fill in the rest. These find whole hosts nobody remembered, which is a larger unit of forgotten than an endpoint.
7. Cloud inventory.
aws apigateway get-rest-apis, load balancer target groups, Lambda function URLs, Cloud Run services. The cloud console is an inventory nobody thinks to treat as one.
Fixing it
Give every endpoint an owner and an expiry. The root cause is not technical. Endpoints are created by people who move on, and nothing in the process ever asks "is this still needed?" A required owner field and a scheduled review turn a permanent liability into a periodic decision.
Make retirement a real step with a real procedure. Most zombie endpoints exist because nobody was confident enough to switch them off. Give them a defined path: announce, log every caller with an identifying header, notify them, return 410 Gone for a period, then remove. The intermediate stage, log every caller, is what converts "we think nobody uses it" into evidence.
Deny by default at the gateway. Route allowlisting, not path blocklisting. A new route that is not registered should not be reachable from the internet at all. This is the single control that stops shadow endpoints from being exposed even when they are created, and it converts the whole problem from "find them all" into "they are not reachable until someone registers them."
Generate the inventory from the code. A documentation page maintained by hand drifts within one sprint. A route table generated at build time and diffed in CI cannot. Fail the build when a route appears with no owner, no auth annotation, or no test.
Keep production quiet. No /swagger.json, no /actuator, no /debug, no verbose errors, no stack traces. If your framework serves those by default, that default is a decision you should be making deliberately.
Detection
- Requests to paths with no registered route. A steady, low-volume stream of
404s from one source across many candidate paths is discovery. This is the earliest signal you get and it is usually available already. - Traffic to endpoints your current clients do not call. If no released client version calls
/v1/export, every request to it deserves attention. - A dormant endpoint waking up. Near-zero traffic for months, then a spike. That is not a returning user.
- User-agent anomalies on internal-only routes. Anything that is not your own service identity.
- Version-adjacent probing. The same path requested under
/v1/,/v2/,/v3/in sequence from one source.
Take this away
Your API inventory is a document about what people remember. Your attack surface is a property of what your servers answer.
Those two things drift apart continuously, in one direction, and the gap only ever grows. The fix is not a better document. It is generating the inventory from the running system, and making the gateway refuse anything that is not on it.
Further reading
Was this useful?
Comments
Loading comments…