You shipped a fix. You cut a new version. You moved the frontend. The old version is still live because nothing tells you how to turn it off, and one customer on an old mobile build is still calling it. Six months later, an attacker finds both versions and picks the one that still has the bug.
Improper Inventory Management is the bug where you do not know what you have running. Old API versions that were never turned off. Staging and beta subdomains pointed at prod data. Undocumented routes from a prototype nobody deleted. Every one of them is a live attack surface the team has stopped watching.
What your AI actually built
You asked for a versioned API. /api/v1 for the original launch, /api/v2 once you added multi-tenant support and fixed that nasty ownership bug. The model did exactly what you asked — it created v2 alongside v1.
What it did not do was retire v1. It did not put a 410 Gone on it, did not add a deprecation header, did not log which clients were still hitting it. v1 sits in the repo looking exactly like v2 except for the one thing you fixed.
The other flavor of this bug is the staging server. A subdomain called beta. or dev. or internal. that points at a slightly older build of the same app, often with looser auth, debug endpoints on, and the same database behind it.
How it gets exploited
The attacker notices the main app is careful: every route checks ownership, every token is scoped. They assume there is an older version somewhere.
- 1Look for ghostsThey try /api/v1, /api/legacy, and api-staging.example.com. /api/v1/invoices/123 responds — with the IDOR that v2 no longer has.
- 2EnumerateA script walks /api/v1/invoices/1 through 20000. Every invoice comes back, because v1 was never scoped to the session user.
- 3Pivot to the staging hoststaging.example.com runs the same app against the production database. The staging build still has /api/debug/sql enabled. One query dumps the users table.
- 4Walk into prodThe leaked users table has session tokens from yesterday. Half of them are still valid. The attacker is now logged in as real production users.
The production app was clean. The production-adjacent surface was not. Nobody on the team could have told you staging was still pointing at the prod database — it had been that way since launch and nobody changed it.
Vulnerable vs Fixed
// app/api/v1/invoices/[id]/route.ts (shipped 2024, never retired)
export async function GET(req, { params }) {
// Original ownership bug — no session check.
const invoice = await db.invoice.findUnique({
where: { id: params.id },
});
return Response.json(invoice);
}
// app/api/v2/invoices/[id]/route.ts (the "fixed" version)
export async function GET(req, { params }) {
const session = await getSession(req);
const invoice = await db.invoice.findUnique({
where: { id: params.id, userId: session.user.id },
});
return Response.json(invoice);
}// app/api/v1/[...all]/route.ts
export async function GET() {
return new Response(
JSON.stringify({
error: 'API v1 retired on 2025-01-15. Use /api/v2.',
}),
{
status: 410,
headers: {
'Content-Type': 'application/json',
'Deprecation': 'true',
'Sunset': 'Wed, 15 Jan 2025 00:00:00 GMT',
},
},
);
}
export const POST = GET;
export const PUT = GET;
export const DELETE = GET;
export const PATCH = GET;Deleting routes from the repo is not enough if a build is still deployed somewhere. Replace every v1 route with a single catch-all that returns 410 Gone, a Sunset header, and a clear pointer to v2. Do the same thing for staging and beta subdomains — if they do not need to exist, they should return 410, not a working response.
A real case
USPS — 60 million records leaked via an old, forgotten API
In 2018, a researcher found an undocumented "Informed Visibility" API on usps.com that let any logged-in user query any other account — the endpoint existed for a year before anyone noticed, because nobody had an inventory of what was live.
Related reading
References
Find the versions you forgot were live.
Flowpatrol walks every version, subdomain, and shadow route of your app and shows which ones still have the bugs you already fixed. Five minutes. One URL.
Try it free