π§ Old API Versions: Find the /v1 That Never Died, Then Prove It
Teams ship /api/v2, fix the authorization bug that /v1 had, update the docs, and move on. The router never got the memo. /api/v1/orders/1002 still answers, it still talks to the same database, and it still has the bug.
Teams ship /api/v2, fix the authorization bug that /v1 had, update the docs, and move on. The router never got the memo. /api/v1/orders/1002 still answers, it still talks to the same database, and it still has the bug.
That's the whole idea behind OWASP's API9:2023 Improper Inventory Management: you can't secure an API version you've forgotten you run. This post is about finding those forgotten versions and hosts, and then doing the part that matters: proving, with your own test accounts, whether the old door actually skips a check the new one enforces.
Only test what you own or are explicitly authorized to test. Everything below uses stand-in hosts (api.example.com) and test accounts.
What API9 actually says
The OWASP entry describes attackers getting in through old API versions or endpoints that were left running unpatched with weaker security requirements. It also notes that different versions or deployments are sometimes connected to the same database with real data, which is what turns "an old version exists" into "an old version leaks today's data".
OWASP splits the problem into two blind spots:
- Documentation blind spot: nobody can say which environment a host belongs to, who should reach it, which version it runs, or when it retires.
- Data flow blind spot: sensitive data goes to a third party with no inventory or approval behind it.
This post is about the first one, because that's the one you can find and prove from the outside.
Why the old version is weaker
An old version isn't dangerous because it's old. It's dangerous when it differs from the current one in a way that matters. Three patterns cover most of it:
- The fix only landed in the new code path. The ownership check was added to the v2 controller. The v1 controller still reads the order by ID and returns it.
- The control lives in front of one host, not in the API. OWASP's first scenario is exactly this: rate limiting on password-reset tokens sat in a component in front of the main API host, and a beta host running the same API had no such component. Same code, different front door.
-
A non-production deployment with production data. A
stagingorbetahost that was set up quickly, with debug settings or older dependencies, pointed at real data.
In all three cases the version or host is only half the finding. The other half is the difference in behavior, and that's what you have to show.
Where forgotten versions hide
Treat everything in this section as a list of leads. A URL that answers isn't a vulnerability yet.
Sibling versions in the path. If the app calls /api/v3/orders/1001, ask the same question for v0, v1, v2, v4 and no version at all. PortSwigger's API testing guide makes the related point about base paths: when you find /api/swagger/v1/users/123, also look at /api/swagger/v1, /api/swagger and /api.
Version in a header or query string. Some APIs pick the version from a header or a query parameter instead of the path. If the docs or client code send one, try the older values the same way.
Documentation files. OpenAPI and Swagger files (/openapi.json, /swagger/index.html and friends) often list more than the UI uses. In OpenAPI 3.1 an operation can carry deprecated: true, which means "consumers should stop using this", not "this is turned off". A deprecated operation in a spec is a great lead.
Older clients. Old JavaScript bundles, cached pages and older mobile app builds still contain the endpoints they were built against. I covered the bundle side in Hidden API Endpoints in JavaScript Bundles.
Archived URLs. The Wayback Machine's CDX server returns captured URLs for a prefix. A query like the one below lists API paths that were public at some point, including versions that have since vanished from the docs:
curl -s "http://web.archive.org/cdx/search/cdx?url=app.example.com/api/*&output=json&fl=original&collapse=urlkey&limit=500"
Other hosts. beta., staging., api-v1. and the like show up in DNS enumeration and certificate transparency logs (crt.sh is one place to search them). Check the program's scope before you send a single request to a new host: a hostname being public doesn't make it in scope.
Response headers. RFC 9745 (March 2025) defines a Deprecation response header, and RFC 8594 (May 2019) defines Sunset, the date a resource is expected to stop responding. If you see either, the team knows the version is on its way out. RFC 9745 is explicit that deprecation doesn't change how the resource behaves, so a deprecated version still deserves the same tests.
A quick, read-only sweep
Once you have one known endpoint, this script asks every sibling version once, with your own token, and prints the status and body size. It doesn't try to bypass anything; it just tells you which doors exist.
"""List which sibling versions of a known endpoint answer. Read-only, one GET each."""
import re, sys, urllib.request, urllib.error
def candidates(url, highest=4):
m = re.search(r"/v(\d+)/", url)
if not m:
return [url]
cur = int(m.group(1))
out = [url[:m.start()] + f"/v{n}/" + url[m.end():] for n in range(0, max(cur, highest) + 1)]
out.append(url[:m.start()] + "/" + url[m.end():]) # unversioned
return out
def status(url, token):
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"})
try:
with urllib.request.urlopen(req, timeout=10) as r:
return r.status, len(r.read())
except urllib.error.HTTPError as e:
return e.code, len(e.read())
if __name__ == "__main__":
known_url, token = sys.argv[1], sys.argv[2]
for u in candidates(known_url):
code, size = status(u, token)
print(f"{code} {size:>5} {u}")
On a real target you'd run python version_sweep.py https://api.example.com/api/v2/orders/1001 "$TOKEN_A". Against a local stand-in API where v2 is current and v1 was never retired, it prints:
404 22 http://127.0.0.1:8088/api/v0/orders/1001
200 50 http://127.0.0.1:8088/api/v1/orders/1001
200 50 http://127.0.0.1:8088/api/v2/orders/1001
404 22 http://127.0.0.1:8088/api/v3/orders/1001
404 22 http://127.0.0.1:8088/api/v4/orders/1001
404 22 http://127.0.0.1:8088/api/orders/1001
So v1 is alive and returns the same object to its owner. That's the lead. It is not a finding: a working old version that enforces the same rules is a cleanup ticket, not a vulnerability.
Prove it: same object, two accounts, every version
The proof is the same discipline as any authorization test, run once per version. You need two accounts you control (A and B) and one object each of them owns. For each live version, send four requests:
| Request | What it tells you |
|---|---|
| A reads A's object | The version works for its owner (control) |
| B reads B's object | B's object exists and B's token works (control) |
| A reads B's object | The actual test |
| No token reads B's object | Whether authentication is enforced at all |
If the current version blocks "A reads B's" and the old version returns B's object to A, you've proven the gap: same data, same accounts, one version enforces the rule and the other doesn't. If the controls fail on the old version, the result is inconclusive, not a finding.
"""Same object, same two accounts, every live version. Four requests per version."""
import json, sys, urllib.request, urllib.error
def get(url, token):
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"})
try:
with urllib.request.urlopen(req, timeout=10) as r:
return r.status, json.loads(r.read() or b"{}")
except urllib.error.HTTPError as e:
return e.code, {}
def check(template, version, token_a, token_b, id_a, id_b):
url = lambda oid: template.format(v=version, id=oid)
rows = {
"A reads own": get(url(id_a), token_a),
"B reads own": get(url(id_b), token_b),
"A reads B's": get(url(id_b), token_a),
"no token": get(url(id_b), ""),
}
own_ok = rows["A reads own"][0] == 200 and rows["B reads own"][0] == 200
cross = rows["A reads B's"]
leaked = cross[0] == 200 and cross[1] == rows["B reads own"][1]
if not own_ok:
verdict = "INCONCLUSIVE (version not working for owners)"
elif leaked:
verdict = "PROVEN: A received B's object"
else:
verdict = f"blocked ({cross[0]})"
print(f"{version}: " + " ".join(f"{k}={v[0]}" for k, v in rows.items()) + f" -> {verdict}")
return leaked
if __name__ == "__main__":
# usage: prove_version_gap.py "<url with {v} and {id}>" v2 v1 ...
# TOKEN_A / TOKEN_B: your two test accounts. ID_A / ID_B: an object each one owns.
import os
env = os.environ
found = [check(sys.argv[1], v, env["TOKEN_A"], env["TOKEN_B"], env["ID_A"], env["ID_B"])
for v in sys.argv[2:]]
sys.exit(1 if any(found) else 0)
Run it as python prove_version_gap.py "https://api.example.com/api/{v}/orders/{id}" v2 v1 with the four environment variables set. Against the same local stand-in:
v2: A reads own=200 B reads own=200 A reads B's=404 no token=401 -> blocked (404)
v1: A reads own=200 B reads own=200 A reads B's=200 no token=401 -> PROVEN: A received B's object
And after the stand-in's v1 gets the same ownership check:
v2: A reads own=200 B reads own=200 A reads B's=404 no token=401 -> blocked (404)
v1: A reads own=200 B reads own=200 A reads B's=404 no token=401 -> blocked (404)
A few rules keep this proof clean:
-
Compare the body, not just the status. The script only calls it proven when A gets back exactly what B gets for B's own object. A
200with an empty body or an error message in JSON isn't a leak. - Use your own two accounts. Never read a real user's object to "confirm". If the test works with B, it works; that's the point of B.
- Detection isn't proof for the edge-control case either. If you suspect the pattern from OWASP's scenario (a rate limit on one host but not the other), don't brute-force anything to show it. Note the evidence you can collect safely, such as rate-limit headers present on one host and absent on the other, and ask the program before sending any volume.
For the report, put the four rows for both versions side by side. That table is the finding: it shows the rule exists, shows where it's missing, and gives the team the exact requests to replay after the fix. (For how much of that to put into a severity score, I wrote about scoring only the impact you proved.)
How to fix it (and keep it fixed)
OWASP's prevention list starts with inventory, and that's the honest place to start, but a spreadsheet alone doesn't close a door. What works in practice:
Keep a real inventory. For every API host: environment, who should be able to reach it, which version it serves, who owns it, and when it retires. The first three come straight from OWASP's list, and OWASP counts a missing retirement plan as part of the blind spot. If a row has no retirement date, it will still be there next year.
Put authorization below the version layer. If v1 and v2 controllers each do their own ownership check, they'll drift. Route both through one shared policy function or service so a fix lands everywhere at once. When a new version adds a security fix, OWASP's advice is to decide explicitly for the older versions: backport it, or retire them quickly.
Protect every exposed version, not just the current one. Both OWASP and PortSwigger say this almost word for word. That includes edge controls: rate limits and WAF rules attached to one hostname don't protect a second hostname that reaches the same backend.
Retire on purpose. Announce it with Deprecation and Sunset headers and a Link to the migration notes, then actually turn it off. After the sunset date, answer with 410 Gone, which RFC 9110 defines as access being no longer available and the condition likely permanent. A 410 is easy to monitor for; a forgotten 200 isn't.
Keep production data out of non-production. If a staging host must use it, OWASP says it gets the same security treatment as production.
Test every version the router still serves. Make the cross-account check part of CI and loop it over the live versions, so v1 can't quietly regress:
import os, unittest
from prove_version_gap import get
BASE = os.environ.get("API_BASE", "http://127.0.0.1:8088")
LIVE_VERSIONS = ["v1", "v2"] # every version your router still serves
class CrossAccountRead(unittest.TestCase):
def test_no_version_leaks_another_users_order(self):
for v in LIVE_VERSIONS:
with self.subTest(version=v):
own, _ = get(f"{BASE}/api/{v}/orders/1001", "tok_test_alice")
self.assertEqual(own, 200, "control: owner must still read it")
cross, _ = get(f"{BASE}/api/{v}/orders/1002", "tok_test_alice")
self.assertIn(cross, (403, 404))
if __name__ == "__main__":
unittest.main()
Against the stand-in before the fix, it fails on exactly one subtest, version='v1', with AssertionError: 200 not found in (403, 404). After the fix it passes. Better still, generate LIVE_VERSIONS from your router or gateway config instead of typing it, so a version nobody remembers can't escape the test.
Try it yourself
All of these are built to be attacked:
- OWASP crAPI: Challenge 3, "Reset the password of a different user", is the one to try. Its hint about the predictable nature of REST APIs is worth taking literally. (challenges list)
- PortSwigger Web Security Academy: Exploiting an API endpoint using documentation practises the base-path walk, and Finding and exploiting an unused API endpoint practises finding what the UI never calls.
- Your own stand-in: a local server of a few dozen lines with a v1 and a v2 route is enough to try both scripts above and watch the verdict flip when you add the check to v1.
Wrapping up
A forgotten version is a lead. A forgotten version that hands account A the data of account B, while the current version refuses, is a finding. The gap between those two sentences is four requests per version and two accounts you own. Find the doors, then prove which ones are open, then make CI check all of them so the old one can't drift again.
Further reading
- OWASP API9:2023 Improper Inventory Management
- PortSwigger Web Security Academy: API testing
- RFC 9745: The Deprecation HTTP Response Header Field
- RFC 8594: The Sunset HTTP Header Field
- RFC 9110 Β§15.5.11: 410 Gone
-
OpenAPI Specification 3.1.0 (the
deprecatedfield on operations) - Wayback CDX Server API
- CWE-1059: Insufficient Technical Documentation
- OWASP crAPI
I'm Krishna, a pentester and the founder of Xalgorix, an open-source AI pentester that only reports vulnerabilities it has actually exploited.
π If this was useful, follow me for more hands-on web & API security write-ups.
π Connect with me on LinkedIn: https://www.linkedin.com/in/xalgord/
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.