Migrating API Keys to OAuth2: A Runbook
Migrating consumers from API keys to OAuth2 is a coordination problem wearing a technical costume. The gateway work is a day; persuading forty integrations to change how they authenticate takes months, and the only version of this that succeeds is one where both credentials work simultaneously for as long as it takes. This page is that procedure.
Prerequisite concepts
This assumes the validation mechanics in authentication proxying and token validation and the consumer-communication discipline from deprecation lifecycle management.
Accept both, on the same route
# Kong 3.x — both plugins on the route, neither mandatory alone
plugins:
- name: key-auth
route: orders
config: { key_names: ["apikey"], anonymous: "$ANON_ID" } # falls through
- name: jwt
route: orders
config: { anonymous: "$ANON_ID" } # falls through
- name: request-termination
route: orders
config: { status_code: 401, message: "credentials required" }
consumer: "$ANON_ID" # only the anonymous consumer is rejected
The pattern is the standard one for accepting either credential: each auth plugin falls through to an anonymous consumer rather than rejecting, and a terminating plugin scoped to that anonymous consumer rejects only requests that satisfied neither. The important detail is that both plugins must map to the same consumer record when they succeed.
Tracking who has moved
-- the report that actually matters, run weekly
SELECT consumer_id,
max(request_time) AS last_seen,
count(*) FILTER (WHERE cred = 'key') AS key_requests,
count(*) FILTER (WHERE cred = 'bearer') AS token_requests
FROM gateway_access_log
WHERE request_time > now() - interval '7 days'
GROUP BY consumer_id
HAVING count(*) FILTER (WHERE cred = 'key') > 0
ORDER BY key_requests DESC;
Every row is a conversation to have. The rows at the bottom — one request a week from an integration nobody remembers — are the ones that turn a planned switch-off into an incident.
The sequence
- Add token acceptance to every route, mapped to the same consumers. Nothing changes for anyone.
- Issue credentials to each consumer and confirm they can obtain a token, before asking them to switch.
- Publish the schedule, with the switch-off date and the migration guide, using the response headers from sunset header and deprecation response patterns.
- Report weekly on consumers still using keys, and contact them individually rather than by broadcast.
- Brownout the key path for short announced windows once the list is small.
- Switch off by rejecting key auth, keeping the route and returning a body that names the migration guide.
Decision matrix
| Consumer type | Approach |
|---|---|
| Actively maintained integration | schedule and self-service |
| Vendor-supplied client | involve the vendor early; their release cycle is the constraint |
| Internal batch job | migrate first — you control both ends |
| Unidentified low-volume caller | brownout is the only way to find the owner |
| Contractually pinned integration | negotiate before publishing the date |
Gotchas and failure signals
Keys in URLs live forever in logs, bookmarks and proxy caches. That is a reason to migrate and also a reason to expect the key to keep arriving after the client has been updated — often from a cache or a retry, not from the current code.
Token expiry breaks assumptions built on keys. A client that stored a key in configuration and never thought about it now needs a refresh loop. Say so explicitly in the migration guide; it is the most common cause of a failed switch.
Do not change the quota at the same time. Two variables in one migration means every support conversation starts by determining which one caused the problem.
Keep the route after switch-off. Returning a 401 with a body pointing at the guide is far more useful than a 404 from a deleted route.
Validation
- Both credentials resolve to the same consumer record, verified per consumer
- Quota and billing continuity confirmed across a credential switch
- Weekly report of consumers still using keys, not just request share
- Migration guide covers token refresh, not only token acquisition
- Brownout windows announced and short
- Post switch-off, the route returns 401 with a pointer rather than 404
FAQ
How do I accept both credentials on one route?
Configure both auth plugins to fall through to a shared anonymous consumer instead of rejecting, then add a terminating plugin scoped to that anonymous consumer. A request carrying either credential is authenticated; one carrying neither hits the terminator and gets a 401. The critical detail is that both plugins must resolve to the same consumer record when they succeed.
Why track consumers rather than request volume?
Because request share falls fast and misleads. The largest integrations migrate first, so ninety percent of traffic can move within weeks while two thirds of the integrations have not started. Switching off when the request graph reaches the floor breaks every small integration simultaneously, and those are the ones without an engineer watching.
What breaks most often during this migration?
Token expiry. A client that stored an API key in configuration years ago now needs a refresh loop, and clients that only refresh after receiving a 401 fail in bursts. Make refresh handling the most prominent part of the migration guide, and do not change quotas at the same time — two variables makes every support conversation start with a diagnosis.
What should happen after the key path is switched off?
Keep the route and return 401 with a body naming the migration guide, rather than deleting the route and returning 404. A 404 tells an integrator their URL is wrong and sends them looking for a new endpoint; a 401 with a pointer tells them exactly what changed and where to read about it.
Parent: Authentication Proxying & Token Validation
Related
- Authentication Proxying & Token Validation — the validation mechanics for both credential types.
- Token Introspection vs Local JWT Validation — how to validate the tokens you are migrating to.
- Deprecation Lifecycle Management — the escalation ladder this runbook borrows.