How-to › Release and secure packages

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.

Audience
API producer
Level
intermediate
Topic
Harden clients and servers
Languages
TypeScript and JavaScript
Verified

A key is rotated on a Friday and three customers are down by Monday. The announcement went to the address on the account, which belongs to somebody who left. Nothing in your system could say whether anyone was still using the old key, so the decision to cut it off was made from a calendar entry.

What you get

You will end up with a key ring that holds two live keys, records which one each request used, and reports how long the old one has been idle. The retirement then follows evidence. This is for you if you issue API keys and have ever postponed a rotation.

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 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 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

ApproachWhen it fitsWhat it costs youWhen to pick something else
AWS Secrets Manager staged rotationYou are on AWS and the secret is consumed by services you controlA rotation Lambda per secret type, and staging labels to understandYour consumers are customers rather than your own services
An overlap window you runYou issue keys to customers and need usage evidence before cutting one offThe two-key logic and the usage records, which are yours to buildA managed rotation already covers every consumer
HashiCorp Vault dynamic credentialsShort-lived credentials issued per workload, with leases and revocationVault in the request path, and clients that can fetch and renewCustomers integrate once and keep a key for years
sekreto as the loading interfaceYou want one call to load a secret across several backendsA library on the consumer side, which changes nothing about rotation itselfThe 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.

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.

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.

node demo.mjs
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.

node --test keyring.test.mjs
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.

Last verified

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

Footnotes

  1. Node has had timingSafeEqual 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. ↩︎ Back to text

  2. Secrets Manager keeps its history under labels. It keeps three labels, 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. ↩︎ Back to text

  3. Vault’s answer to a window with no end is a default. Its default_lease_ttl and 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. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.