API Versioning Strategies Without Creating Security Debt
#Your v1 Endpoint Is a Permission Differential, Not a Compatibility Layer#link
In February 2026, a patch landed for CVE-2026-23760 in SmarterMail, rated CVSS 9.3. The vulnerability was not in the v2 API. It was in /api/v1/auth/force-reset-password, an endpoint decorated with AllowAnonymous = true while every newer endpoint in the same application required credentials. The endpoint was being actively exploited in production at the time of disclosure. Nobody had removed it because removing it might break a client. Nobody had added authentication to it because it was deprecated and low priority. The v1 endpoint was not a compatibility artifact. It was an unauthenticated password reset primitive sitting next to a hardened v2 API, and attackers found it the way they find every deprecated route: by changing a single character in the URL.
TL;DR: API versioning creates a permission differential, not a migration. When v2 adds authentication, rate limiting, or tenant validation, v1 often keeps running with none of those controls because the business constraint of not breaking clients overrides the security requirement of retiring old code. The result is zombie APIs -- endpoints that were once known and sanctioned but have fallen out of maintenance and out of the patch cycle. Salt Security measured the scale: 40 percent of APIs in production do not match their documentation, and enterprises have on average three times more endpoints than security teams know about. This post covers the attack patterns, the real CVEs, and the versioning lifecycle controls that prevent security debt from accumulating.
Zombie APIs, Shadow APIs, and the Inventory Blind Spot
The security debt created by versioning has a name. A zombie API is an old, forgotten, or deprecated endpoint that is still deployed and still accepting traffic even though nobody is actively maintaining or monitoring it. It was once known and sanctioned -- it appeared in documentation, it had an owner, it was part of the intended surface -- but it has since fallen out of the patch cycle. A shadow API is its cousin: an endpoint that was never in the inventory to begin with, created by an unauthorized team or tool and never brought under governance. Both share the fatal property of being outside your security controls. Both are recognized in the OWASP framework. API9:2019 covers improper inventory management, and the newer BLA10:2025 category, Shadow Function Abuse, explicitly lists deprecated functionality that should have been removed but stays accessible in production as one of its three root causes.
The difference between a zombie and a shadow API matters for remediation, not for risk. A zombie API is a cleanup problem: you know it existed, you can find its owner, you can trace its dependencies, and you can plan its retirement. A shadow API is a discovery problem: you do not know it exists until an attacker uses it or a scanner finds it. The versioning pattern produces zombies. The versioning pattern combined with poor inventory discipline produces shadows. Most organizations have both, and the security team usually knows about neither.
| Property | Zombie API | Shadow API |
|---|---|---|
| Was it ever documented? | Yes -- it was once known and sanctioned | No -- it was never in the inventory |
| Does it receive traffic? | Often, from forgotten clients or attackers probing for /v1 | Yes, from internal tools, partner integrations, or attackers |
| Is it patched? | No -- it is out of the maintenance cycle | No -- it is not in the asset inventory |
| Is it monitored? | No -- alerts are tuned to the active surface | No -- nobody expects traffic to it |
| Primary remediation | Sunset and decommission | Discover and govern |
| OWASP category | API9:2019, BLA10:2025 | BLA10:2025, API9:2019 |
The SesameOp Backdoor: When a Deprecated API Becomes a C2 Channel
The SesameOp backdoor, disclosed by Microsoft DART in November 2025, is the most instructive versioning security incident of the last year because the deprecated API was not the target -- it was the weapon. The backdoor used OpenAI's Assistants API as a command-and-control channel. The malware, a .NET backdoor called OpenAIAgent.Netapi64, fetched commands from the Assistants API, decrypted and executed them locally, and sent results back as messages. It used compression and layered encryption to keep both the incoming payload and the outgoing results hidden. The Assistants API is expected to be deprecated by OpenAI in August 2026 and replaced by the Responses API. During its deprecation window, the API remained fully functional and fully accessible to any client with a valid key. That accessibility is what made it a usable C2 channel. An organization that had restricted outbound traffic to approved API endpoints would not have blocked this channel, because the Assistants API was an approved endpoint. The deprecation status did not reduce the API's availability. It only removed it from active development, which made it a stable, unchanging relay that attackers could depend on.
The SesameOp case is a warning about how deprecated APIs behave. A deprecated API is not a degraded API. It is a frozen API. It does not receive new security controls, but it does not lose its existing capabilities either. Its authentication model is whatever it was on the day it was frozen. Its rate limits are whatever they were. Its logging is whatever it was. For an attacker who needs a stable relay that will not change under them, a deprecated API is better than an active one. The active API might add rate limiting next quarter. The deprecated API will not.
The WSO2 Try-It Feature: Deprecated Code, Live SSRF
CVE-2025-5350 in multiple WSO2 products is a cleaner example of a deprecated feature becoming a live vulnerability. The Try-It feature, a built-in API testing interface for administrators, was deprecated but still present in the codebase and still reachable. The feature accepted user-supplied URLs without proper validation, leading to server-side request forgery. The retrieved content was directly reflected in the HTTP response, enabling reflected cross-site scripting in the admin user's browser context. An attacker who tricked an administrator into accessing a crafted link could force the server to fetch malicious content and reflect it into the admin's browser, leading to arbitrary JavaScript execution for UI manipulation or data exfiltration. The vulnerability affected WSO2 API Control Plane 4.5.0 and API Manager versions 3.1.0 through 4.5.0 -- a span of five major releases over multiple years. The feature was deprecated in the sense that it was no longer actively developed, but it was not removed, and it was not protected. Deprecation without removal is not a security control. It is a delay.
Deprecation is a label, not an enforcement mechanism. An endpoint marked deprecated in documentation but still reachable in production is an endpoint that attackers can use. The only deprecated endpoint that is safe is one that has been blocked at the gateway, removed from the routing table, or explicitly denied by a middleware rule. A comment in the code that says TODO: remove before v3 is not a security control.
The Middleware Ordering Trap: Why v1 Misses What v2 Enforces
The structural reason v1 endpoints lack the controls that v2 endpoints enforce comes down to middleware registration order. In most web frameworks, routes are registered with a router, and middleware is registered either globally or scoped to a path prefix. When a team adds authentication middleware for a new version, the middleware is typically scoped to the new version's path prefix. The old version's routes predate that middleware. They were registered before it existed, and they are not retroactively covered by it. The Express.js pattern makes this concrete and common.
// v1 routes registered first, before any auth middleware existedapp.use('/api/v1/', v1Router);// Months later: v2 ships with authenticationapp.use('/api/v2/', authMiddleware);app.use('/api/v2/', v2Router);// The result:// GET /api/v2/users -> authMiddleware runs -> 401 without token// GET /api/v1/users -> v1Router handles it -> 200 without token// Same handler logic. Same data. Different security posture.// The version increment created a permission differential.
Live JavaScript preview
The fix is not to remember to apply middleware to every version path. The fix is to invert the default. Authentication, rate limiting, input validation, and authorization checks should be global middleware that runs on every request, with explicit opt-outs for the small number of endpoints that genuinely need to be anonymous -- health checks, public documentation, and the login endpoint itself. The pattern of registering auth middleware per-version-prefix guarantees that the next version you ship will have the same gap as the last one, because the developer adding v3 will follow the same pattern they used for v2.
// Correct pattern: auth is global, opt-out is explicit and reviewedapp.use(authMiddleware); // runs on every request by defaultapp.use(rateLimitMiddleware); // runs on every request by defaultapp.use(inputValidationMiddleware);// Explicit allowlist for anonymous endpointsconst PUBLIC_ROUTES = new Set(['/health','/api/auth/login','/api/docs',]);// authMiddleware checks request.path against PUBLIC_ROUTES// and rejects any request that is not explicitly allowlisted.
Live JavaScript preview
The Enumeration Problem: Ten Paths to the Same Backend
A versioned API has more reachable paths than its documentation suggests. The OpenAPI spec documents /api/v2/. Dynamic scanners consume that spec and test the listed endpoints. They do not test the undocumented paths that reach the same backend controller. An API at /api/v2/ has at least ten other reachable paths that automated scanners ignore. Path-based version variants include /api/v0/, /api/v1/, /api/v3/, /api/beta/, /api/internal/, /api/dev/, /api/admin/, /api/test/, and /api/staging/. None of those routes need to appear in documentation to respond to HTTP traffic. Format variants for the same version reach the same controller: /API/V1/, /api/1/, /api/1.0/, and /api/v1.0/ differ by capitalization and dot notation. Most routing frameworks treat all of these as equivalent, routing to the same handler with different authentication configuration. An attacker who knows /api/v2/ exists has roughly an 80 percent chance that /api/v1/ also responds. Header-based versioning is invisible to path-oriented scanners entirely: Accept: application/vnd.api+json;version=1 and API-Version: 1 specify the version without modifying the URL, making the older version opaque to tools that analyze paths. DNS and subdomain variants extend the surface further: api-v1.target.com and v1.api.target.com often point to the same code with different auth configuration.
# The enumeration pattern an attacker usesfor path in /api/v0 /api/v1 /api/v2 /api/v3 /api/beta /api/internal /api/dev /api/admin /api/test /api/staging; dostatus=$(curl -s -o /dev/null -w "%{http_code}" "https://target.example.com${path}/users")echo "${path}: ${status}"done# Expected output on a vulnerable API:# /api/v0: 404# /api/v1: 200 <- deprecated, no auth# /api/v2: 401 <- current, auth required# /api/v3: 404# /api/beta: 200 <- undocumented, no auth# /api/internal: 200 <- internal, reachable from internet# /api/dev: 200 <- dev route, never removed
The 404 on some paths does not mean the backend is safe. A 404 on /api/v0/users may mean the route does not exist, or it may mean the route exists but does not match that path pattern. The 200 responses are the signal. A 200 from a path that is not in the documentation is a zombie or shadow endpoint that needs to be investigated.
Versioning Strategies and Their Security Characteristics
The versioning strategy you choose affects how much security debt you accumulate and how visible that debt is. Path-based versioning is the most common and the most transparent: the version is in the URL, so the surface is enumerable. It is also the easiest for attackers to probe, because the version is in the URL. Header-based versioning hides the version from path-based scanners, which reduces casual discovery but does not eliminate the surface. Query-parameter versioning is a middle ground that is easy to implement and easy to overlook. Each has different implications for how quickly you can find and retire old versions.
| Strategy | How It Works | Security Characteristic |
|---|---|---|
| Path-based | /api/v1/users, /api/v2/users | Most transparent and most enumerable. Easy to probe, easy to block at the gateway. The version is visible in logs. |
| Header-based | Accept: application/vnd.api+json;version=1 | Less enumerable by path scanners, but invisible to access logs that only record the path. Harder to audit which version a client is using. |
| Query-parameter | /api/users?version=1 | Easy to implement and easy to overlook. Clients may share the same path with different version parameters, making per-version rate limiting difficult. |
| Subdomain | v1.api.example.com | Separate DNS entries and often separate TLS certificates. Clear separation but more infrastructure to manage and monitor. |
| Date-based | API-Version: 2026-09-12 | Common in payment APIs. The version represents a snapshot date. Old versions are explicit and can be sunset by date. |
The security characteristic that matters most is not which strategy is chosen but whether the version is visible in the audit trail. If a request to /api/v1/users and a request to /api/v2/users both appear in the log as /users, you cannot determine which version is being used without inspecting the header. For header-based and query-parameter versioning, the log schema must explicitly capture the version field. Otherwise you have traffic on a deprecated version and no way to see it. The Salt Security statistic -- 40 percent of APIs in production do not match their documentation -- is in part a measurement of how many organizations cannot tell which version a client is using because the version is not logged.
Versioning Without Security Debt: The Lifecycle Controls
The controls that prevent versioning from creating security debt are not exotic. They are a set of lifecycle policies and enforcement mechanisms that treat the version as a security-relevant attribute, not just a routing prefix. The goal is to make it impossible for a deprecated version to remain reachable without an explicit, reviewed decision to keep it alive.
- Make authentication, rate limiting, and input validation global middleware, not per-version middleware. The Express pattern of app.use('/api/v1/', v1Router) and app.use('/api/v2/', authMiddleware) is the root cause of the permission differential. Auth runs on every request by default, with an explicit allowlist for anonymous endpoints. This is the single highest-leverage control.
- Block deprecated routes at the gateway, not just in the documentation. A route that is marked deprecated in the OpenAPI spec but still reachable is a zombie. The gateway should return 410 Gone for deprecated path prefixes, enforced by configuration that is reviewed and version-controlled, not by a comment in the code.
- Maintain a version inventory with sunset dates. Every version of every API should have an entry with its current status (active, deprecated, sunset), its sunset date, and the clients still using it. The OWASP API9 guidance is explicit: establish policies limiting concurrent active versions, track which applications depend on each version, set firm sunset dates, and communicate changes to consumers.
- Log the API version on every request. Whether the version is in the path, the header, or a query parameter, it must appear in the audit log as a queryable field. Without it, you cannot detect traffic on deprecated versions, and you cannot measure the impact of a sunset decision.
- Alert on traffic to deprecated versions. A request to /api/v1/users after v1 has been marked deprecated is a signal. It may be a forgotten client, or it may be an attacker probing for the weakest version. Either way, it warrants investigation. The alert should fire before the sunset date, not after.
- Run the same security tests against every active version. A SAST scan that runs against the v2 codebase does not cover the v1 route handler if the handler code is separate. If the handler is shared, the test covers both. If the handler is forked, the test covers neither. Confirm that your security testing covers every reachable version, not just the current one.
- Enforce per-version authorization, not just per-version routing. A common mistake is to route /api/v1/ and /api/v2/ to the same handler with the same authorization logic, assuming that the version prefix is the only difference. The SmarterMail CVE shows the opposite: the v1 handler had AllowAnonymous = true, which the v2 handler did not. Authorization rules must be defined per version and tested per version.
- Treat the sunset as a security control, not a product decision. Removing a version is a breaking change for clients, which is why it is hard. But keeping a version alive indefinitely is a security debt that compounds. Set a maximum lifetime for deprecated versions -- 6 to 12 months is typical, with 18 to 24 months as the absolute ceiling -- and enforce it. If a client cannot migrate in that window, the cost of their migration is less than the cost of the vulnerability that will eventually be found in the frozen version.
- Discover the shadow surface before an attacker does. Traffic logs from gateways, load balancers, and reverse proxies record every path that receives requests. Compare that against the documented API surface. Paths receiving traffic that are not in the documentation are candidates for both zombie and shadow endpoints. Run this comparison on a schedule, not as a one-off exercise.
The Sunset Policy That Actually Gets Enforced
The reason deprecated versions survive is not technical. It is organizational. Removing a version requires someone to make the call that breaking a client is acceptable, and that call is usually deferred because no one wants to be the person who took down a partner integration. The way to make the call easier is to make it automatic and scheduled. A sunset date that is set at the time the version is deprecated, communicated in advance, and enforced by a gateway rule that a human has to explicitly override is a sunset that happens. A sunset that depends on someone remembering to remove the code is a sunset that never happens.
# Gateway configuration: deprecated versions return 410 after sunset# This configuration is version-controlled and reviewed.# Extending a sunset requires a pull request with a security review.routes:- path: /api/v1/*status: deprecatedsunset_date: "2026-03-01"handler: 410 # Gone -- no routing to backend after sunsetalert_on_traffic: true- path: /api/v2/*status: activehandler: backend-v2- path: /api/v3/*status: activehandler: backend-v3
The 410 Gone status code is the correct response for a retired endpoint. It tells the client that the resource is intentionally and permanently removed, which is different from a 404, which suggests the resource never existed or the path is wrong. A 410 is also a clear signal in the access log that the client is using a retired version, which makes monitoring for stragglers straightforward.
What to Do First
The versioning security debt in most organizations is larger than any single team can fix in a sprint. The highest-leverage first step is not to inventory everything. It is to make the next version you ship not create the same debt. Switch authentication and rate limiting to global middleware with an explicit anonymous allowlist. That one change prevents the permission differential from recurring in every future version. The second step is to measure the surface you already have: pull the gateway logs for the last 30 days, enumerate every path that received a 200 response, and compare that list against your OpenAPI spec. The paths that appear in the logs but not in the spec are your zombie and shadow candidates. You do not need to fix them all at once. You need to know they exist.
The third step is to set a sunset policy and enforce it at the gateway. A version that is marked deprecated but still routable is a version that will be exploited. The CVE-2026-23760 pattern -- an anonymous password reset endpoint on a deprecated version, actively exploited in production -- is the predictable outcome of leaving a route alive because removing it felt risky. The risk of removing it is a client migration. The risk of keeping it is an unauthenticated endpoint that attackers will find. The migration is cheaper.