API versioning with scopes
How to evolve an OAuth2-protected API without breaking clients
You have v1 of your API live, with api:read and api:write scopes. You want to launch v2. How?
Versioning strategies
Path-based versioning
/api/v1/orders
/api/v2/ordersTwo parallel implementations. Scopes are the same:
api:read → can read on v1 or v2
api:write → can write on v1 or v2Client opts into version via URL. Scopes don't need to change.
Scope-per-version
api.v1:read api.v1:write
api.v2:read api.v2:writev2 introduces new scope names. Old clients keep v1 scopes; new clients get v2.
Pros: explicit, easy to deprecate v1 (don't issue api.v1 scopes to new clients). Cons: scope sprawl.
Header-based versioning
GET /api/orders
Accept: application/vnd.your-app.v2+jsonURL stays the same. Server dispatches by Accept header. Scopes don't change.
Recommendation for Olympus
Path-based + new scopes for additive new functionality.
api:read → r/o on existing data
api:write → r/w on existing data
api:admin → admin endpoints (new in v2)
api:billing → billing endpoints (new in v2)Don't version the scope itself unless the meaning of api:read changed (rare).
Deprecation
When announcing v1 deprecation:
- Communicate timeline (e.g., 12 months).
- Sunset header on v1 responses:
Sunset: Sat, 31 Dec 2026 23:59:59 GMT Deprecation: true Link: <https://your-domain.com/api/v2>; rel="successor-version" - Email clients using v1 monthly with usage stats.
- Hard-cut after sunset; v1 returns 410 Gone.
Token lifecycle during deprecation
Tokens issued before deprecation continue working for their lifetime (30d for refresh). Don't break them mid-flight.
New tokens issued after deprecation: optional warnings via custom headers or OAuth2 client metadata.
Per-client API versions
Pin clients to specific API versions:
hydra update client <id> \
--metadata '{"api_version": "v2"}'Your API reads the metadata after introspection:
const introspect = await introspectToken(token);
const version = introspect.client_metadata?.api_version ?? "v1";
const handler = handlers[version];
return handler(req);Useful for B2B where each customer might be on different versions.
Scope migrations
You realize api:read covers too much. Split into:
orders:readusers:readinventory:read
Migration:
- Issue tokens with both old and new for transition period:
api:read orders:read users:read inventory:read - Server accepts both (
api:readORorders:read). - After 6 months, server only accepts new.
- Old
api:readstill valid technically; just not useful.
Don't break tokens. Always overlap.
Documenting scopes
In your API reference:
## GET /api/v2/orders
Requires scope: `orders:read`
Returns the user's orders.Be explicit per endpoint. Don't make consumers guess.
OpenAPI spec:
paths:
/orders:
get:
security:
- oauth2: [orders:read]Scope marketing
Some scopes sound scary ("api:admin", "users:delete"). Use friendly names in consent UI:
const labels = {
"orders:read": "View your orders",
"orders:write": "Modify your orders",
"billing:write": "Manage your billing",
};Display these in Hera's consent screen.
Public API vs Internal
Internal scopes (used only by your own apps): less curation needed. Public API (3rd party developers): high care, scope names are forever.