01 //Introduction
API versioning is essential for evolving your API without breaking existing clients. However, every version you ship becomes a permanent part of your attack surface until it is explicitly decommissioned. Attackers routinely scan for deprecated API versions because they often lack security patches, weaker authentication, and missing authorization checks that were added in later releases.
This guide teaches you how to review API versioning code for security issues, spot version downgrade attacks, and ensure deprecated endpoints are properly shut down rather than silently serving vulnerable logic.
In 2023, a major fintech breach was traced to an attacker calling a deprecated v1 endpoint that still allowed password resets without MFA verification — a control added in v2. The v1 route was "deprecated" in documentation but still fully operational in production.
- Version downgrade attacks — Attackers target older API versions that lack newer security controls
- Shadow endpoints — Deprecated versions running silently alongside current ones
- Inconsistent security policies — Rate limits, auth, and validation differ between versions
- Data model leakage — Old versions may expose fields removed for privacy in newer versions
02 //Versioning Strategies & Attack Surface
Each versioning strategy introduces a different attack surface. Understanding these trade-offs is critical during code review.
/api/v1/users → /api/v2/users
Accept: application/vnd.api+json;version=2
/api/users?version=1
Accept: application/vnd.api.v2+json
URL path versioning (/api/v1/, /api/v2/) is the most common approach. It is explicit and easy to understand, but creates a discoverability problem: attackers can enumerate versions by simply decrementing the version number in the URL.
1// Express.js — no validation on version parameter
2app.use('/api/:version/users', (req, res, next) => {
3 const { version } = req.params;
4 // No check if this version is still supported!
5 // An attacker can request /api/v0/users or /api/v1/users
6 const handler = versionHandlers[version];
7 if (handler) {
8 return handler(req, res, next);
9 }
10 next();
11});1const SUPPORTED_VERSIONS = new Set(['v3', 'v2']);
2const CURRENT_VERSION = 'v3';
3
4app.use('/api/:version/users', (req, res, next) => {
5 const { version } = req.params;
6
7 if (!SUPPORTED_VERSIONS.has(version)) {
8 return res.status(410).json({
9 error: 'Gone',
10 message: `API ${version} is no longer supported. Use ${CURRENT_VERSION}.`,
11 docs: `https://api.example.com/docs/${CURRENT_VERSION}`,
12 });
13 }
14
15 const handler = versionHandlers[version];
16 handler(req, res, next);
17});Header-based versioning hides version information from the URL, which can reduce casual enumeration. However, the security risk shifts to default version behavior — what happens when no version header is sent?
1function getApiVersion(req) {
2 const versionHeader = req.headers['api-version'];
3 // Dangerous: defaults to v1 which may lack security patches
4 return versionHeader || 'v1';
5}1function getApiVersion(req) {
2 const versionHeader = req.headers['api-version'];
3
4 if (!versionHeader) {
5 return CURRENT_VERSION; // Always default to the latest, most secure version
6 }
7
8 if (!SUPPORTED_VERSIONS.has(versionHeader)) {
9 throw new ApiVersionError(
10 `Version ${versionHeader} is not supported. Supported: ${[...SUPPORTED_VERSIONS].join(', ')}`
11 );
12 }
13
14 return versionHeader;
15}A REST API uses query parameter versioning (/api/users?v=1). A client sends a request without the version parameter. What is the safest default behavior?
03 //Version Downgrade Attacks
A version downgrade attack occurs when an attacker deliberately calls an older API version to bypass security controls introduced in newer versions. This is analogous to TLS downgrade attacks, where a client forces a server to use a weaker protocol.
During code review, look for security-critical logic that exists only in newer version handlers. If v2 introduced MFA verification, rate limiting, or field-level authorization, but v1 still handles the same resource without those controls, you have a version downgrade vulnerability.
1# v1 handler — NO authorization check
2@app.route('/api/v1/users/<user_id>', methods=['GET'])
3def get_user_v1(user_id):
4 user = db.users.find_one({'_id': user_id})
5 return jsonify(user) # Returns ALL fields including SSN, DOB
6
7# v2 handler — with proper authorization and field filtering
8@app.route('/api/v2/users/<user_id>', methods=['GET'])
9@require_auth
10@require_scope('users:read')
11def get_user_v2(user_id):
12 if current_user.id != user_id and not current_user.is_admin:
13 abort(403)
14 user = db.users.find_one({'_id': user_id})
15 return jsonify(filter_sensitive_fields(user))1from functools import wraps
2
3def version_security_baseline(f):
4 """Apply minimum security controls regardless of API version."""
5 @wraps(f)
6 @require_auth
7 @require_scope('users:read')
8 @rate_limit('100/hour')
9 def decorated(*args, **kwargs):
10 return f(*args, **kwargs)
11 return decorated
12
13@app.route('/api/v1/users/<user_id>', methods=['GET'])
14@version_security_baseline
15def get_user_v1(user_id):
16 user = db.users.find_one({'_id': user_id})
17 return jsonify(filter_sensitive_fields(user))
18
19@app.route('/api/v2/users/<user_id>', methods=['GET'])
20@version_security_baseline
21def get_user_v2(user_id):
22 if current_user.id != user_id and not current_user.is_admin:
23 abort(403)
24 user = db.users.find_one({'_id': user_id})
25 return jsonify(filter_sensitive_fields(user))If you see security decorators/middleware applied to newer version routes but NOT to older versions of the same endpoint, flag it immediately. Every active version must meet the current security baseline.
Your API v2 added RBAC (role-based access control) to the /admin/users endpoint. The v1 endpoint is still active. What is the correct remediation?
04 //Legacy Endpoint Risks
Legacy endpoints are a goldmine for attackers. They often expose more data, have weaker validation, and fly under the radar of security monitoring. Here are the most common patterns to watch for during code review.
Legacy Endpoint Risk Matrix
| Risk | Description | Severity |
|---|---|---|
| Excessive data exposure | Older versions return fields (SSN, tokens) that were removed in newer versions | Critical |
| Missing input validation | Schema validation added in v2 not present in v1 | High |
| Weak authentication | v1 uses API keys while v2 requires OAuth 2.0 + MFA | Critical |
| No rate limiting | Rate limiting middleware only applied to v2+ routes | High |
| Verbose error messages | v1 returns stack traces and internal details in errors | Medium |
| Missing audit logging | Activity logging added in v2 but v1 has no audit trail | Medium |
1// v1 — Returns full user object including sensitive data
2router.get('/api/v1/users/:id', async (req, res) => {
3 const user = await User.findById(req.params.id);
4 res.json(user); // Includes: ssn, dateOfBirth, internalNotes, passwordHash
5});
6
7// v2 — Properly filters response
8router.get('/api/v2/users/:id', async (req, res) => {
9 const user = await User.findById(req.params.id)
10 .select('name email avatar role createdAt');
11 res.json(user);
12});1// v1 error handler — leaks implementation details
2app.use('/api/v1', (err: Error, req: Request, res: Response, next: NextFunction) => {
3 res.status(500).json({
4 error: err.message,
5 stack: err.stack, // Leaks file paths and dependencies
6 query: (err as any).sqlQuery, // Leaks SQL queries
7 });
8});
9
10// v2 error handler — generic messages only
11app.use('/api/v2', (err: Error, req: Request, res: Response, next: NextFunction) => {
12 logger.error('API error', { error: err, requestId: req.id });
13 res.status(500).json({
14 error: 'Internal server error',
15 requestId: req.id,
16 });
17});During code review, compare every active version side-by-side: (1) Do all versions use the same auth middleware? (2) Do all versions validate input with the same schema strictness? (3) Do all versions filter response fields identically? (4) Do all versions have the same rate limiting? (5) Do all versions log to the same audit pipeline?
05 //Code Review Patterns
When reviewing API versioning code, focus on these patterns that indicate potential security issues.
Pattern 1: Version parameter injection. If the version identifier is user-controlled and used in dynamic routing, path construction, or database lookups, it can be exploited.
1// DANGEROUS: User-controlled version used to load modules
2app.use('/api/:version', (req, res, next) => {
3 const version = req.params.version;
4 // Path traversal via version: "../../etc/passwd"
5 const handler = require(`./handlers/${version}/users`);
6 handler(req, res, next);
7});1const VERSION_HANDLERS = {
2 v2: require('./handlers/v2/users'),
3 v3: require('./handlers/v3/users'),
4};
5
6app.use('/api/:version', (req, res, next) => {
7 const handler = VERSION_HANDLERS[req.params.version];
8 if (!handler) {
9 return res.status(400).json({ error: 'Invalid API version' });
10 }
11 handler(req, res, next);
12});Pattern 2: Inconsistent middleware chains. Different versions should share a security baseline. Look for version-specific middleware configurations that accidentally omit security layers.
1// v1 routes — missing critical middleware
2const v1Router = express.Router();
3v1Router.use(bodyParser.json());
4v1Router.get('/users/:id', getUserV1);
5v1Router.post('/users', createUserV1);
6
7// v2 routes — full security stack
8const v2Router = express.Router();
9v2Router.use(bodyParser.json());
10v2Router.use(helmet()); // Security headers — missing in v1!
11v2Router.use(rateLimiter); // Rate limiting — missing in v1!
12v2Router.use(validateApiKey); // Auth — missing in v1!
13v2Router.use(sanitizeInput); // Input sanitization — missing in v1!
14v2Router.get('/users/:id', getUserV2);
15v2Router.post('/users', createUserV2);
16
17app.use('/api/v1', v1Router);
18app.use('/api/v2', v2Router);1function createSecureRouter() {
2 const router = express.Router();
3 router.use(bodyParser.json({ limit: '10kb' }));
4 router.use(helmet());
5 router.use(rateLimiter);
6 router.use(validateApiKey);
7 router.use(sanitizeInput);
8 router.use(auditLogger);
9 return router;
10}
11
12const v1Router = createSecureRouter();
13v1Router.get('/users/:id', getUserV1);
14
15const v2Router = createSecureRouter();
16v2Router.get('/users/:id', getUserV2);
17
18app.use('/api/v1', v1Router);
19app.use('/api/v2', v2Router);Pattern 3: Version sprawl without lifecycle management. Look for codebases where many versions coexist without any version retirement logic or sunset headers.
1// Red flag: 5+ active versions with no lifecycle management
2app.use('/api/v1', v1Router); // Released 2019 — still active?
3app.use('/api/v2', v2Router); // Released 2020
4app.use('/api/v3', v3Router); // Released 2021
5app.use('/api/v4', v4Router); // Released 2022
6app.use('/api/v5', v5Router); // Released 2023
7// No deprecation dates, no sunset headers, no usage trackingYou're reviewing an Express.js API and notice that v1Router uses `app.use(cors({ origin: '*' }))` while v2Router uses `app.use(cors({ origin: allowedOrigins }))`. What type of vulnerability is this?
06 //Secure Version Routing
Proper version routing architecture is foundational to API versioning security. The routing layer determines which versions are accessible, how requests are dispatched, and where security controls are enforced.
1interface VersionConfig {
2 status: 'active' | 'deprecated' | 'retired';
3 sunsetDate?: Date;
4 router: Router;
5 securityLevel: 'standard' | 'elevated';
6}
7
8const API_VERSIONS: Record<string, VersionConfig> = {
9 v3: { status: 'active', router: v3Router, securityLevel: 'elevated' },
10 v2: {
11 status: 'deprecated',
12 sunsetDate: new Date('2026-06-01'),
13 router: v2Router,
14 securityLevel: 'elevated',
15 },
16 // v1 is retired — not in the map at all
17};
18
19function versionGateway(req: Request, res: Response, next: NextFunction) {
20 const version = req.params.version;
21 const config = API_VERSIONS[version];
22
23 if (!config) {
24 return res.status(410).json({
25 error: 'This API version has been retired',
26 currentVersion: 'v3',
27 docs: 'https://api.example.com/docs/v3',
28 });
29 }
30
31 if (config.status === 'deprecated') {
32 res.setHeader('Deprecation', 'true');
33 res.setHeader('Sunset', config.sunsetDate!.toUTCString());
34 res.setHeader('Link', '<https://api.example.com/docs/v3>; rel="successor-version"');
35 }
36
37 req.apiVersion = version;
38 config.router(req, res, next);
39}
40
41app.use('/api/:version', versionGateway);Version Status HTTP Responses
| Version Status | HTTP Code | Headers | Behavior |
|---|---|---|---|
| Active | 200 | None extra | Normal processing |
| Deprecated | 200 | Deprecation: true, Sunset: | Process + warn client |
| Retired | 410 Gone | Link: | Reject with migration info |
| Unknown | 400 | None | Reject invalid version |
1@Component
2public class ApiVersionFilter extends OncePerRequestFilter {
3
4 private static final Set<String> ACTIVE_VERSIONS = Set.of("v3", "v2");
5 private static final Set<String> RETIRED_VERSIONS = Set.of("v1");
6 private static final String CURRENT_VERSION = "v3";
7
8 @Override
9 protected void doFilterInternal(HttpServletRequest request,
10 HttpServletResponse response, FilterChain chain)
11 throws ServletException, IOException {
12
13 String version = extractVersion(request.getRequestURI());
14
15 if (RETIRED_VERSIONS.contains(version)) {
16 response.setStatus(HttpServletResponse.SC_GONE);
17 response.getWriter().write(
18 "{\"error\":\"API " + version + " is retired. Use " + CURRENT_VERSION + "\"}");
19 return;
20 }
21
22 if (!ACTIVE_VERSIONS.contains(version)) {
23 response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
24 response.getWriter().write("{\"error\":\"Invalid API version\"}");
25 return;
26 }
27
28 chain.doFilter(request, response);
29 }
30}Centralize version routing in a single gateway/middleware. This guarantees every request is version-checked, deprecated versions emit sunset headers, and retired versions are blocked. Avoid scattering version logic across individual route handlers.