API Versioning Security: Code Review Guide
Table of Contents
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.
Why This Matters
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
Versioning Strategies & Attack Surface
Each versioning strategy introduces a different attack surface. Understanding these trade-offs is critical during code review.
API Versioning Strategies & Attack Surface
/api/v1/users → /api/v2/usersRisk: Old paths remain accessible after deprecationAccept: application/vnd.api+json;version=2Risk: Missing header defaults to vulnerable version/api/users?version=1Risk: Parameter tampering to reach old versionsAccept: application/vnd.api.v2+jsonRisk: Content-type confusion can bypass securityKey Insight: Every versioning strategy has a unique attack surface. The most dangerous risk is that deprecated versions remain reachable and lack the security patches applied to newer versions.
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.
Vulnerable: URL path versioning without version validation
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});Secure: Explicit version allowlist with strict routing
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?
Vulnerable: Defaulting to oldest version when header is missing
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}Secure: Default to latest version, require explicit opt-in for older
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?
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.
Version Downgrade Attack Flow
GET /api/v1/admin/usersTargets deprecated v1Routes to v1 handlerNo version blockingMissing RBAC check!Returns all user dataImpact: v2 added role-based access control, but v1 still runs without it. Attackers bypass v2 security by calling the same endpoint on v1.
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.
Vulnerable: Security control only in v2, v1 still active
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))Secure: Shared security middleware applied to ALL versions
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))Code Review Red Flag
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?
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 |
Vulnerable: v1 returns sensitive fields removed in v2
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});Vulnerable: Different error handling reveals internal details
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});Review Checklist for Legacy Endpoints
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?
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.
Vulnerable: Version parameter used in dynamic file loading
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});Secure: Version validated against allowlist before use
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.
Vulnerable: Security middleware only on newer versions
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);Secure: Shared security baseline for all versions
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.
Code smell: Unbounded version routes with no deprecation tracking
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?
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.
Secure version routing with lifecycle management (Node.js)
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: <date> | Process + warn client |
| Retired | 410 Gone | Link: <docs>; rel="successor-version" | Reject with migration info |
| Unknown | 400 | None | Reject invalid version |
Secure version routing with gateway pattern (Spring Boot)
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}Best Practice: Version Gateway Pattern
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.