APIs & developer platforms / Migration guide
From API Keys to Scoped Tokens: Upgrading a Public API's Auth Without Breaking Clients
Migrate a public API from all-powerful API keys to scoped, expiring tokens: design the scopes, run both systems in parallel, and deprecate keys without breaking clients.

This guide walks you through replacing long-lived, all-powerful API keys with scoped, expiring tokens on a public API that real clients already depend on. You will design the scope model, issue tokens that coexist with the old keys, move enforcement into middleware that understands both, and run a deprecation programme that ends with keys disabled — without a flag day, and without silently breaking anyone’s integration. The examples are Node.js and Express, but every step is about sequencing and policy, not framework features.
Why keys have to go
A classic API key has three properties that were fine when your API had twelve users and are liabilities now:
- It grants everything. One credential can read, write, delete, and administer. A key leaked from a client-side bundle, a log line, or a screenshot grants the whole account.
- It never expires. Keys are valid until someone notices and revokes them, and nothing in the credential’s lifecycle forces that review.
- It carries no context. The key does not say which integration it belongs to, what it is for, or what it should be allowed to touch, so your logs cannot answer “what does this leaked key expose?”
Scoped tokens fix all three: each token carries an explicit permission set, an expiry, and an owner-chosen name tying it to an integration. This is not a foreign idea — it is the model behind personal access tokens on developer platforms and OAuth 2.0 access tokens — and your clients have used it elsewhere even if your API has not offered it yet.
Step 1: Design the scope model before writing code
Scopes are the part of this migration you cannot easily redo, because clients will hard-code them into their token requests and runbooks. Design rules that survive contact with reality:
Name scopes by resource and action, in a consistent grammar: orders:read, orders:write, billing:read, webhooks:manage. Avoid a * wildcard scope and avoid an admin scope that silently means “everything, including scopes invented later” — a token minted today must not gain powers when you ship a new resource next quarter. New resources require new scopes, and old tokens simply do not have them.
Keep the catalogue small enough to print. If your scope list needs a search box, developers will respond by requesting all of them, and you have rebuilt the API key with extra steps. Group by what an integration legitimately does, not by your internal service boundaries.
Map the old world honestly. Existing keys are implicitly all scopes. During migration, a token’s default suggestion in your dashboard can be “the scopes your current usage needs” — which you can compute from your own request logs — but the token itself must carry an explicit list. That distinction matters legally as well as technically: a scope the customer selected is a permission they granted knowingly, while a scope your system silently attached is a permission you assumed, and the two read very differently in an incident review.
| Design question | Safer default |
|---|---|
| Token lifetime | Expiring (days to months), renewable; non-expiring only as an explicit, warned choice |
| Scope granularity | Resource + action (orders:read) |
| Wildcards | None; new resources are never retroactively granted |
| Who can mint tokens | Account admins; tokens themselves cannot mint tokens |
| Token visibility | Full value shown once at creation; afterwards only a prefix and metadata |
Step 2: Token format and storage
A token is a bearer credential, so treat its storage like a password’s:
import { createHash, randomBytes } from "node:crypto";
const PREFIX = "pk_live_";
export function mintToken(): { display: string; lookupHash: string } {
// The full token is shown to the user exactly once.
const secret = randomBytes(32).toString("base64url");
const display = `${PREFIX}${secret}`;
// Store only a hash. Lookup is by hash, so a database leak
// does not hand an attacker working credentials.
const lookupHash = createHash("sha256").update(display).digest("hex");
return { display, lookupHash };
}The prefix does three jobs: it makes tokens recognisable in logs and secret-scanning tools (yours and your clients’), it distinguishes environments (pk_test_ versus pk_live_), and it gives support a safe partial identifier — a client can read out the first characters of their token’s prefix-plus-ID segment without disclosing the secret. Store alongside the hash: the account, the scope list, an expiry timestamp, a human name (“Warehouse sync job”), creation and last-used timestamps, and a revoked flag. Last-used is not decoration; it is how you will run the deprecation in step 5.
Should tokens be opaque strings (looked up in your database) or self-contained signed tokens (JWTs)? For a public API you operate, opaque wins by default: revocation is a flag flip, scope changes take effect immediately, and there is no window where a revoked-but-unexpired JWT still works. Self-contained tokens earn their complexity when verification must happen in services that cannot call back to a central store. Most APIs migrating from keys are not there.
Step 3: Middleware that speaks both languages
The migration’s mechanical core is an auth layer that accepts a token or a legacy key, resolves either to the same internal principal — account plus granted permissions — and enforces scopes from that point on. Legacy keys resolve to all scopes (that is what they always meant), but every request they make gets logged as legacy usage.
import type { Request, Response, NextFunction } from "express";
export interface Principal {
accountId: string;
scopes: Set<string>; // empty set + legacy flag for old keys
legacy: boolean;
credentialId: string;
}
export function requireScope(scope: string) {
return (req: Request, res: Response, next: NextFunction) => {
const principal = req.principal as Principal;
// Legacy keys predate scopes: they pass, but the usage is recorded
// so the deprecation programme can see exactly who still depends on them.
if (principal.legacy) {
req.log.warn({ credentialId: principal.credentialId }, "legacy key used");
return next();
}
if (!principal.scopes.has(scope)) {
return res.status(403).type("application/problem+json").json({
type: "about:blank",
title: "Insufficient scope",
status: 403,
detail: `This token does not have the ${scope} scope.`,
requiredScope: scope,
});
}
return next();
};
}Two details in that middleware are the migration in miniature. The 403 body names the missing scope, so a developer’s first encounter with scoping is self-explanatory rather than a support ticket. And legacy usage is logged per credential, which turns “we think most clients have migrated” into a list of exactly who has not, and which endpoints they still call with keys.
Step 4: The migration sequence
The rollout has four phases, and the order is not negotiable:
| Phase | What changes | Exit criteria |
|---|---|---|
| 1. Tokens available | Dashboard and API can mint tokens; middleware accepts both; scopes enforced for tokens only | Tokens work end to end; docs published |
| 2. Tokens default | New integrations are issued tokens, not keys; dashboard steers existing users to convert, pre-filled with scopes inferred from their logged usage | Majority of request volume is on tokens |
| 3. Keys deprecated | Key creation disabled; every legacy request logged and surfaced in the customer’s dashboard (“this key was used this week, from these endpoints”); notice period running with direct outreach to remaining key users | Legacy volume is a short, named list |
| 4. Keys disabled per account | Keys stop working account by account, starting with dormant ones; a break-glass re-enable exists, time-boxed, for the inevitable exception | No production traffic on keys |
Phase 3 is where migrations are won. “We emailed everyone” is not an outreach programme. You have per-credential usage logs: you know which accounts still call with keys, which endpoints they touch, and whether the key is dormant or load-bearing. Dormant keys can be disabled with a courtesy notice. Active ones get a named, specific conversation: “your key ending in this prefix called these endpoints this week; here is a token pre-scoped to exactly that usage.” That specificity is only possible because you built the logging into the middleware before you needed it.
Step 5: Communication, headers, and the endgame
Announce the schedule where developers actually look: the developer changelog, the dashboard (targeted at accounts with active keys, not a banner for everyone), and direct email to the owners of active keys. Use your own response surface too — during phases 2 and 3, legacy-key responses can carry Deprecation and Sunset headers pointing at the migration guide. Clients who log headers will find the notice in their own telemetry; clients who do not were never going to read the blog post either.
Hold the line on the endgame with two exceptions pre-decided, because they will arrive: a dormant key that turns out to be load-bearing for a quarterly job (the time-boxed re-enable exists for this — with a token conversion as its condition), and an enterprise client whose change process needs a longer window (grant it individually, in writing, with a date — do not move the global deadline, which teaches everyone that deadlines are negotiable).
When the last account is converted, delete the legacy branch from the middleware. A dual-auth codebase kept “just in case” is a permanent second attack surface and a standing invitation for the next engineer to route around scopes. The migration is finished when the code path is gone, not when the last email is sent.
Verdict
Migrate, and migrate in this order: scopes designed conservatively, tokens built to be revocable and observable, middleware that unifies both credentials behind one principal and logs legacy usage per credential, then a phased deprecation driven by that usage data rather than by hope. The technical work is a few weeks; the reason migrations fail is never the token format — it is skipping the observability that makes the deprecation a list of named accounts instead of a leap of faith. Build the logs first, and the rest of the programme runs itself.


