# How to rotate API keys without breaking your clients

> Run two keys at once, watch which one each caller uses, and retire the old one on evidence rather than on a date somebody picked.

Source: https://voxgig.com/howto/rotate-api-keys-without-breaking-clients

- Audience: api-producer
- Level: intermediate
- Languages: typescript, javascript
- Verified: 2026-09-14
- Published: 2026-09-14
- Updated: 2026-09-24

## Short answer

Accept two keys at a time. Issue the new one, leave the old one live, and record which key every request used. When the old key has been idle long enough, give it a retirement instant and let it expire. A rotation that ends on a date rather than on usage ends with somebody's integration down.

---
## You will need

Node 22 or later, and an API whose keys you issue. The comparison is done with
[timingSafeEqual](https://nodejs.org/api/crypto.html#cryptotimingsafeequala-b) over hashes, because
a key check that returns early on the first wrong byte leaks how much of a guess was right.[^1] The
[OWASP guidance on key
management](https://cheatsheetseries.owasp.org/cheatsheets/Key_Management_Cheat_Sheet.html) covers
the storage side of the same problem.

Voxgig maintains sekreto. This page compares the rotation itself with AWS Secrets Manager and Vault,
and mentions sekreto only as the interface a client loads a secret through.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [AWS Secrets Manager staged rotation](https://docs.aws.amazon.com/secretsmanager/latest/userguide/rotating-secrets.html) | You are on AWS and the secret is consumed by services you control | A rotation Lambda per secret type, and staging labels to understand | Your consumers are customers rather than your own services |
| [An overlap window you run](https://nodejs.org/api/crypto.html) | You issue keys to customers and need usage evidence before cutting one off | The two-key logic and the usage records, which are yours to build | A managed rotation already covers every consumer |
| [HashiCorp Vault dynamic credentials](https://developer.hashicorp.com/vault/docs/concepts/lease) | Short-lived credentials issued per workload, with leases and revocation | Vault in the request path, and clients that can fetch and renew | Customers integrate once and keep a key for years |
| [sekreto as the loading interface](https://github.com/voxgig/sekreto) | You want one call to load a secret across several backends | A library on the consumer side, which changes nothing about rotation itself | The consumers are customers who never see your code |

The distinction that matters is who holds the credential. A rotation among your own services can be
automated end to end, because something you run can fetch the new value. A rotation across customer
integrations cannot, because it waits for somebody at another company to paste a string into a
configuration file, however much tooling you run.

That is why the usage record is the centerpiece here rather than the rotation mechanism. For
internal secrets, use the managed rotation your platform already offers.[^2] For customer keys, the work
is knowing when it is safe to stop accepting the old one.

## Accept two keys, and record which one answered

The verification loop walks every live key and reports the one that matched.

```js title="keyring.mjs"
verify(secret) {
  const offered = digest(secret)
  for (const key of keys.values()) {
    if (clock() >= key.retiresAt) continue
    if (!timingSafeEqual(offered, key.hash)) continue
    const seen = usage.get(key.id)
    seen.calls++
    seen.lastSeen = clock()
    return { ok: true, keyId: key.id }
  }
  return { ok: false, reason: 'no live key matched' }
}
```

Store hashes rather than secrets. The ring never needs the original value after issue, and a
database that holds hashes turns a leak into a smaller incident. It also means a support engineer
cannot read a customer's key out of a table, which removes a whole category of accident.

Record `lastSeen` per key, not per customer. A customer with four integrations moves them one at a
time, and the account looks migrated while one job still holds the old key.

## Retire on idleness, not on a date

The retirement instant goes in only once the usage says it is safe.

```js title="demo.mjs"
console.log(`day 17  old key idle for ${ring.idleFor('k_2025') / DAY} days, ${ring.usageOf('k_2025').calls} calls in total`)
```

An idle period longer than the customer's longest cycle is the signal. A monthly billing job is the
usual trap: the key looks unused for four weeks and is needed on the last day of the month.

Publish the retirement instant to the customer when you set it, and keep accepting until it passes.
A key that stops working without a date attached generates a support ticket with no useful
information in it.

## Check it worked

Run a rotation across three weeks.

```bash
node demo.mjs
```

```text output
day  0  one key in service                 live: k_2025
day  0  client calls with sk_live_old      accepted via k_2025
day 10  new key issued, both accepted      live: k_2025, k_2026
day 10  client calls with sk_live_old      accepted via k_2025
day 10  client calls with sk_live_new      accepted via k_2026
day 17  client calls with sk_live_new      accepted via k_2026
day 17  old key idle for 7 days, 2 calls in total
day 17  old key given a retirement date    live: k_2025, k_2026
day 21  old key retired                    live: k_2026
day 21  client calls with sk_live_old      no live key matched
day 21  client calls with sk_live_new      accepted via k_2026
```

The day 17 line is the one the whole design exists for. Seven days idle and two calls total is
evidence, and a rotation that ends there is a decision rather than a gamble. The old key is refused
on day 21 with the same message any unknown secret gets. That is deliberate. A refusal should not
tell a caller that their key was right last week.

```bash
node --test keyring.test.mjs
```

```text output
1..6
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 133.960925
```

## When it goes wrong

A customer is down after a rotation that looked clean. A job runs less often than your idle window.
Set the window from the customer's slowest cycle, and ask them rather than guessing.

Usage shows the old key in use, but not where. The record has a key id and no context. Store
the source address and user agent alongside, so the answer is a query rather than an email.

The new key never gets used. The customer never received it, or received it and could not find where
to put it. Track adoption per key and chase the accounts that have not moved. A rotation is a
migration, and migrations need a list of who is left.

Both keys were rotated at once by a script. An automation regenerated the whole ring. Make issuing
and retiring separate operations, and never let one call do both. The two-key invariant is worth
asserting in a test, as the sample does, because it is the property everything else rests on.

## When not to do this

Do not run an overlap forever. Two live keys double the surface, and a window with no end is a
second permanent credential.[^3] Set the retirement instant when you issue the new key, and extend it
deliberately if the evidence says to.

Do not rotate customer keys on a fixed schedule with no usage data. The schedule sounds disciplined
and produces exactly the Monday in the opening paragraph.

Do not treat sekreto or any loading library as the rotation. It changes where a client reads the
value from, which helps your own services and does nothing for a customer holding a key in a
configuration file they edit by hand.

## Related how-tos

- [Keep test and live API keys from crossing environments](/howto/keep-test-and-live-keys-apart)

- [Diagnose a 401 or a 403 from a credential](/howto/diagnose-401-and-403-from-a-credential)

## Last verified

Verified 2026-09-14 against Node 22.22.2. Both output blocks are what the preceding command printed.

[^1]: Node has had [`timingSafeEqual`](https://nodejs.org/api/crypto.html#cryptotimingsafeequala-b)
since v6.6.0, and since v15.0.0 it accepts an `ArrayBuffer` as well. It insists that both
arguments have the same byte length and throws otherwise, which is the second reason this page
compares hashes rather than keys. A digest is always the same length, so the length check never
has anything to say.

[^2]: Secrets Manager keeps its history under labels.
[It keeps three labels](https://docs.aws.amazon.com/secretsmanager/latest/userguide/whats-in-a-secret.html#term_version),
`AWSCURRENT`, `AWSPREVIOUS`, and `AWSPENDING`, and moves them between versions. A secret may carry
up to 20 labels of your own. A version less than 24 hours old is never removed, and unlabeled
versions are deprecated and removed once there are more than 100. Together, those rules are a
complete rotation policy expressed as four numbers: three labels, twenty of yours, one hundred
versions, and a day.

[^3]: Vault's answer to a window with no end is a default. Its
[`default_lease_ttl` and `max_lease_ttl`](https://developer.hashicorp.com/vault/docs/configuration#max_lease_ttl)
are both `768h`, which is 32 days, and the documentation does not say why. 768 is three times 256
and 32 is a power of two, so the figure is the nearest thing to a month that a power of two can
offer.