API versioning for secure deprecation

Learn how to implement API versioning to deprecate endpoints securely. This Secure development tutorial covers the core concepts, step-by-step application, troubleshooting, and next steps.

Focus: implement api versioning to deprecate securely

Sponsored

Picture this: you've just pushed a breaking change to your API. Instantly, your support inbox floods with messages from angry mobile app developers — their apps are now returning 404s, data is missing, and a handful of your most important enterprise clients are threatening to walk. The root cause? You removed an endpoint that was still being used, and you had no way to roll back gracefully. This is the exact pain that API versioning for secure deprecation solves. Without a deliberate versioning strategy, every change you make is a potential landmine that can break clients, expose sensitive data, or introduce security holes. In this lesson, you'll learn how to implement API versioning to deprecate securely, ensuring that you can evolve your API without breaking existing consumers or compromising security. By the end, you'll have a practical, hands-on framework for versioning that any serious back-end developer needs.

The problem this lesson solves

APIs are contracts. When you expose an endpoint, you're making a promise to every client: "Send me this data in this shape, and I'll give you predictable, correct results." But software evolves. You need to fix bugs, add features, or change data formats. The challenge is doing that without breaking the clients that depend on your API.

Without versioning, you have two bad options:

  1. Never change anything — Your API stagnates, and you can't improve it. Eventually, your app falls behind and becomes a security liability.
  2. Change things abruptly — You break clients, which leads to downtime, frustrated users, and potentially severe security issues if the change affects authentication or authorization logic.

A third, more subtle problem is security: if you deprecate an endpoint without a clear plan, clients may continue to use it even after you've moved to a new one. Attackers can also exploit the confusion — for example, by sending requests to an old endpoint that still has weak validation or by taking advantage of an undocumented, half-removed route. To deprecate securely, you need to control the lifecycle of every version of your API, know who's using what, and ensure that old versions don't become attack surfaces.

This lesson tackles exactly that: how to implement API versioning so that you can deprecate old endpoints safely, communicate changes to clients, and maintain security postures throughout the process.

Core concept / mental model

Think of your API like a public road network. Versioning is like adding new lanes or building a bypass while keeping the old road open for traffic that's already on it. You don't just tear up the old road overnight — you post signs, give drivers time to reroute, and eventually, when nobody is using the old road anymore, you can close it for construction. But you also need guards (security) to make sure no one sneaks into construction zones.

In API terms:

  • API version — A distinct, stable contract that represents a specific state of your API. Version 1 (/api/v1/users) and version 2 (/api/v2/users) are separate contracts, each with its own response formats, validation rules, and security policies.
  • Deprecation — The process of marking a version or endpoint as "retired" while still serving it for a transition period. It signals to developers that they should migrate, but it doesn't cut them off immediately.
  • Transition period — The grace period between announcing deprecation and actually removing the old version. This is when you communicate, monitor, and eventually enforce migrations.

Pro tip: Always version your API from day one, even if you have only one client. It costs little, and it saves you from messy migrations later.

How it works step by step

To deprecate securely, you need to follow a deliberate process. Here's a high-level sequence:

  1. Introduce a new version — When you need to make a breaking change, create a new version (/api/v2/) that contains the updated logic, while keeping the old version (/api/v1/) intact. This gives clients time to migrate.
  2. Document the new version — Update your API docs with clear examples, migration guides, and a list of changes. Make it obvious what's different and why.
  3. Announce deprecation — Notify your developer community via email, changelog, or status page. Set a clear deprecation date — typically 6 to 12 months for public APIs.
  4. Monitor usage — Track which clients are still hitting the deprecated version. Use logging and analytics to see which versions are active and which endpoints are called.
  5. Gradually restrict — During the transition period, you can add a Deprecation header to responses, warning clients. You might also start rate-limiting or adding minor friction to push them to migrate.
  6. Remove only when safe — When usage drops to near zero, or the deprecation date passes, you can remove the old version. But make sure you log all attempts to hit it afterward and return a 410 Gone with a clear message — not a 404.
  7. Maintain security — Throughout this process, treat deprecated endpoints with the same security scrutiny as active ones. Don't forget to patch vulnerabilities in old versions; if they're not maintained, cut them off before they become liability.

The key to secure deprecation is control — you decide when and which route disappears, and you have visibility into who is using it.

Hands-on walkthrough

Let's implement API versioning in a simple Flask app. We'll create a basic user resource with version 1 and version 2, then deprecate v1 securely.

Step 1: Set up a versioned Flask app

First, install Flask if you haven't already:

pip install flask

Now, create app.py with two versions of an endpoint:

from flask import Flask, jsonify, request, make_response, abort

app = Flask(__name__)

@app.route('/api/v1/users/<int:user_id>', methods=['GET'])
def get_user_v1(user_id):
    # Simulate a database
    user = {'id': user_id, 'name': 'Alice', 'email': 'alice@example.com'}
    # Optionally, add a deprecation header
    response = make_response(jsonify(user))
    response.headers['Deprecation'] = 'true'
    response.headers['Link'] = '</api/v2/users/{}>; rel="successor-version"'.format(user_id)
    return response

@app.route('/api/v2/users/<int:user_id>', methods=['GET'])
def get_user_v2(user_id):
    # In v2, we don't return email anymore for privacy reasons
    user = {'id': user_id, 'name': 'Alice'}
    return jsonify(user)

if __name__ == '__main__':
    app.run(debug=True)

Expected output:

If you run this and hit GET /api/v1/users/1, you'll get:

{
  "id": 1,
  "name": "Alice",
  "email": "alice@example.com"
}

And the response header will include Deprecation: true and a Link header pointing to the v2 equivalent.

Step 2: Add a version switch with headers

Instead of routing solely by URL, you can also support header-based versioning, which is useful for internal APIs or when you want to hide URL changes. Here's an example using a custom Accept header:

@app.route('/api/users/<int:user_id>', methods=['GET'])
def get_user_negotiated(user_id):
    # Check the Accept header for version
    accept = request.headers.get('Accept', '')
    if 'application/vnd.myapi.v2+json' in accept:
        # Return v2 format
        user = {'id': user_id, 'name': 'Bob'}
        return jsonify(user)
    # Default to v1
    user = {'id': user_id, 'name': 'Bob', 'email': 'bob@example.com'}
    response = make_response(jsonify(user))
    response.headers['Warning'] = '299 - "Deprecated API version, please migrate to v2"'
    return response

With this, a client requesting Accept: application/vnd.myapi.v2+json gets the new format, while everyone else still gets v1 but with a warning header.

Step 3: Simulate a deprecation notice and 410 Gone

When the time comes to actually remove v1, you can keep the route but return a 410 Gone status to inform clients the resource is no longer available:

@app.route('/api/v1/removed_endpoint', methods=['GET'])
def removed_endpoint():
    # Log the attempt
    app.logger.warning('Deprecated endpoint called: /api/v1/removed_endpoint')
    abort(410, description='This endpoint has been retired. Please use /api/v2/removed_endpoint.')

Expected behavior: A request to this endpoint returns HTTP 410 with a JSON error message, and the app logs the attempt — useful for tracking stragglers.

Compare options / when to choose what

There are three common versioning strategies, and each has trade-offs:

Approach How it works Pros Cons Best for
URI versioning (/api/v1/users) Version in the URL path Simple, explicit, easy to implement, visible to everyone Clutter in URLs, and clients can cache old versions Public APIs, most common choice
Custom header versioning (Accept: application/vnd.myapi.v2+json) Version in the request headers Clean URLs, more flexible content negotiation Harder to test in a browser, requires client cooperation Internal microservices, when API consumers are sophisticated
Query parameter versioning (/api/users?version=2) Version as a query param Easy for developers to tweak, no URL changes Less standard, can be overlooked, and caching can become tricky Simple tools, prototypes, or when you need a quick workaround

For most secure-deprecation scenarios, URI versioning is the recommended approach because it's explicit and easy to reason about. You can still use header-based versioning as a supplement.

When choosing, consider:

  • Your consumers — If you have a public API used by third-party developers, URI versioning is intuitive. If it's internal, header versioning might be cleaner.
  • Compliance needs — If you need to audit what version each client uses, URI versioning makes logging straightforward.
  • Security — Old versions should be clearly isolated, and URI versioning makes it easy to apply different security policies per version.

Troubleshooting & edge cases

Here are common pitfalls and how to avoid them:

  • Missing deprecation headers — If you forget to add Deprecation or Link headers, clients won't get a warning, and they'll keep using old versions indefinitely. Always include these headers in deprecated responses.
  • Returning 404 instead of 410 — When you remove an endpoint, a 404 says "not found," which breaks clients' error handling. Use 410 Gone to signal that the resource used to exist but is now gone, so clients know to update their code.
  • Forgetting to log attempts — If you remove an endpoint and don't track who's still calling it, you might miss a critical client that never migrated. Always log requests to deprecated endpoints.
  • Patching only new versions — Old versions can still have vulnerabilities. If you can't maintain them, cut them off after the deprecation date; otherwise, you're leaving a security hole open.
  • Overly aggressive deprecation — Removing old versions too quickly (e.g., within weeks) can break clients that haven't updated. Give a reasonable transition period and monitor usage before cutting off.
  • Not handling headers in proxies — If you use a reverse proxy, make sure it forwards custom headers like Deprecation and Link, or clients won't see them.

What you learned & what's next

You now have a solid grasp of how to implement API versioning to deprecate securely. You learned:

  • The core concept of API versioning as a way to evolve your API without breaking clients.
  • The step-by-step process for deprecating an endpoint securely, including announcing, monitoring, and removing.
  • How to add versioning to a Flask app using URI and header-based approaches, including deprecation headers.
  • How to return a 410 Gone when an endpoint is retired, and how to log attempts.
  • How to choose the right versioning strategy based on your context.

This directly supports the learning objectives: you can now explain the core idea and complete a practical exercise.

As a next step, consider diving deeper into secure API consumption — for example, how to validate inputs, enforce authentication on versioned endpoints, or handle rate limiting. In the next lesson in this track, you'll explore Input validation posture, where you'll learn to enforce strict schema validation to prevent injection attacks. That's a natural follow-up because versioning and validation often go hand-in-hand: as you introduce new versions, you must ensure they don't introduce new security weaknesses.

Now, go ahead and practice what you've learned — try adding a version switch to one of your existing endpoints and then deprecate it using the techniques above.

Practice recap

Try adding versioning to one of your existing Flask endpoints. Create a v1 and v2, add a Deprecation header to v1, then simulate deprecating v1 by returning 410 Gone and logging the request. Test both versions to confirm clients can migrate smoothly.

Common mistakes

  • Removing a deprecated endpoint without a transition period and without monitoring usage — this breaks clients and exposes security gaps.
  • Forgetting to include deprecation headers (e.g., Deprecation, Link) in old API responses, so clients never migrate.
  • Returning HTTP 404 when an endpoint is retired — use 410 Gone instead so clients know the resource existed but is gone.
  • Patching only the new API version and ignoring vulnerabilities in old versions, leaving them as attack surfaces.

Variations

  1. Use URI versioning (/api/v1/resource) for explicit, simple version control — best for public APIs.
  2. Use header-based versioning (e.g., Accept: application/vnd.myapi.v2+json) for cleaner URLs and better content negotiation.
  3. Use query parameter versioning (/api/resource?version=2) for quick tweaks or prototypes, though it is less standard.

Real-world use cases

  • A SaaS platform deprecating an older authentication endpoint after transitioning to OAuth 2.0, with a 6-month migration window.
  • An e-commerce API versioning its product catalog to remove sensitive customer data (e.g., emails) from responses while keeping v1 for legacy apps.
  • A mobile app backend that used URI versioning to retire a legacy push-notification endpoint, returning 410 Gone to trigger client-side updates.

Key takeaways

  • API versioning lets you evolve your API without breaking existing clients — always version from day one.
  • Secure deprecation involves announcing, monitoring, and gradually restricting usage, not abrupt removal.
  • Use deprecation headers (Deprecation, Link) to point clients to the new version.
  • Return HTTP 410 Gone for retired endpoints and log all attempts to hit them.
  • Maintain security on old versions; if you can't, cut them off to avoid leaving vulnerabilities open.

Sponsored

Sponsored

Discussion

Questions, corrections, and tips help everyone reading this page.

0 comments

Add a comment

Shown publicly with your comment.

Be constructive · max 4,000 characters

No comments yet — start the thread.

Related tutorials, quizzes, and articles for this topic.