You shipped v2 of your API with a tightened authorisation check. v1 is still serving, because turning it off requires knowing who still calls it and nobody has had time to find out.
An attacker does not have to use v2.
Old API versions are the most reliable place to find a bug that was fixed everywhere else, because the fix went into the code path someone was working on, and the old path is maintained by nobody.
This post is how versions accumulate, why that is a security problem rather than a tidiness one, and how to retire them. For whoever owns a public API.
Why old versions stay
Nobody knows who calls them. Deprecation requires a list of affected clients, and if you do not log by version and by client you cannot produce one, so the deadline never gets set.
One large customer. A single integration on v1, belonging to an account nobody wants to disrupt, keeps the whole version alive for everyone including whoever finds it.
Mobile clients you cannot update. An old app version in the wild pins an old API, and its users may never update. This is the genuinely hard case.
It costs nothing visible. A route that still works generates no alert and no ticket. The cost is entirely latent.
The security consequence
Fixes do not propagate. A validation added in the v2 handler is not in v1's unless somebody remembered. When the versions share code this is fine; when they diverged into parallel implementations, which is why you versioned in the first place, it is not.
Old versions are looser by design. Versioning usually happens because the new one is stricter: tighter validation, narrower responses, mandatory parameters that used to be optional, an authorisation check that used to be implicit. Every one of those improvements is a difference an attacker can choose to avoid.
Responses expose more. v1 returned the whole object because that was the style. v2 returns a projection. The field you removed for a good reason is still available.
Nobody tests them. Your test suite exercises the current version. The old paths have no coverage, so a refactor can break their authorisation without a single test failing.
They are excluded from review. Security review looks at what changed. Old versions do not change, so they are never looked at again after the year they shipped.
Find out who is actually calling
You cannot deprecate what you cannot measure, and this is usually one logging change:
version, client_id, user_agent, endpoint, count, last_seen
With that, the conversation becomes concrete. Most old versions turn out to have a handful of callers, several of which are your own internal tools that nobody migrated, and a couple of abandoned integrations that can be turned off tomorrow.
The surprise in most datasets is that the traffic is smaller than feared and the callers are identifiable. The fear of the unknown was doing the work of keeping the version alive.
Retire on a sequence, not a date announcement
Announcing a date and hoping does not move anyone. What moves people is escalating, visible friction:
- Mark deprecated in documentation and in a response header, with a sunset date.
- Notify the identified callers directly, using the list above. Most integrations are maintained by someone reachable.
- Add latency, deliberately, in small increments. A few hundred milliseconds is noticed by systems and not by users.
- Brownout. Return errors for a short window, announced in advance, then restore. An hour of failure on a known date surfaces callers nobody identified, and it does so while people are watching.
- Turn it off, leaving a clear error that says what to use instead.
The brownout is the step that finds the callers your logs missed, and doing it deliberately is much better than discovering them at shutdown.
While it is alive, keep it patched
The rule worth writing down: a security fix applies to every live version, or the version is not live.
That means when a fix is written, the question "which versions need this" is part of the work rather than an afterthought. If maintaining the fix across three implementations is too expensive, that is an argument for accelerating the retirement, not for leaving two of them unfixed.
Include old versions in whatever scanning and testing you do. If your dynamic testing only covers the current version, the paths with the least attention also have the least coverage, which is exactly backwards.
The mobile case
An old mobile client pinned to an old API is the hard version, because you cannot force an update and turning the version off breaks users who did nothing wrong.
The realistic path is the one from mobile generally: a minimum supported version enforced server-side, announced well ahead, with the app prompting to update below it. It is unpleasant and it is bounded, whereas an unversioned commitment to support whatever anyone installed in 2021 is not.
The concession
Versioning exists so you can make breaking changes without breaking customers, and aggressive retirement undermines the promise that made customers comfortable integrating in the first place. A vendor who sunsets versions on a short cycle is a vendor who is expensive to depend on.
So the aim is not a short life, it is a known life: a stated support window, announced at release, so customers can plan. Two or three years with a clear end is a better proposition for everyone than an indefinite commitment that you will eventually break under pressure.
The implication
Every version you still serve is an implementation of your product's security model, frozen at the date it shipped, with no tests and no review.
The useful question is not how many versions you support. It is which of them received your last security fix.