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

Two credentials, one consumer identity The route accepts an API key or a bearer token. Whichever arrives is resolved to the same internal consumer record, so quotas, logs and authorisation continue to work unchanged during the migration and the upstream never learns that anything is in transition. apikey: k_9f21… legacy, being retired authorization: Bearer target resolve to consumer one identity either way quota, logs, upstream headers unchanged throughout Mapping both credentials to one consumer is what keeps quota and billing continuous — without it, a consumer that switches mid-month gets two partial quota windows and an invoice nobody can explain.
# 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

Count consumers, not requests Request share moves quickly because the largest integrations migrate first, reaching ninety percent within weeks. The number of consumers still using a key falls far more slowly, and it is that number — not the request share — that determines when the old credential can be switched off. requests still using a key — falls fast consumers still using a key — falls slowly month 0 month 6 Switching off when the solid line reaches the floor breaks every small integration at once — which is most of them.
-- 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

  1. Add token acceptance to every route, mapped to the same consumers. Nothing changes for anyone.
  2. Issue credentials to each consumer and confirm they can obtain a token, before asking them to switch.
  3. Publish the schedule, with the switch-off date and the migration guide, using the response headers from sunset header and deprecation response patterns.
  4. Report weekly on consumers still using keys, and contact them individually rather than by broadcast.
  5. Brownout the key path for short announced windows once the list is small.
  6. Switch off by rejecting key auth, keeping the route and returning a body that names the migration guide.
Six stages, each with a condition that must hold before the next Dual acceptance ships first and changes nothing for anyone. Credentials are issued and proven before consumers are asked to switch. The schedule is published, weekly reports drive individual contact, brownouts flush out unidentified callers, and switch-off happens only once the consumer count is small and known. 1. accept both 2. issue credentials 3. publish the date 4. report weekly 5. brownout 6. switch off exit: nothing changed for anyone exit: every consumer can get a token exit: headers live on every response exit: list short and every row named exit: no unknown callers left route kept, 401 with a pointer Stage 4 is where the elapsed time goes, and skipping it does not make the migration faster — it moves the discovery of who was still using keys from a spreadsheet into an incident channel.

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