ALL POSTS
api keysapi designengineering

API key rotation without downtime: a provider's guide

Most API key rotation advice is written for the side that consumes the key. This is the other side: how to rotate a key you issued to a customer, prove it is safe to revoke, and keep their usage balance intact.

Sorower

Sorower

Co-founder

Jul 24, 202613 min read
In this article

You rotate a customer's production key on a Friday. Clean swap, new value emailed, ticket closed. Their nightly ETL job, the one nobody has touched in fourteen months, is still holding the old value in an environment variable on a box nobody remembers provisioning. It fails at 03:00. They find out at 09:00. You find out at 09:04, in a tone best described as crisp.

API key rotation without downtime is not really a security task. It is a migration, and the awkward part is that the party doing the migrating does not work for you. Every piece of advice you can find on the subject is written for the other side of the transaction: how your app should rotate the key it holds for Stripe or AWS. This post is about your side, the provider side, where you issue keys to customers and one day have to take one back.

Why API key rotation without downtime is a migration, not a security task

The standard three steps show up in every guide: create a new key, update the applications, delete the old one. That is correct in the way "write the code, then ship it" is correct. It skips the only part that is actually hard.

You do not control your customer's deploy schedule. You control exactly one moment in this process, the moment you revoke, and it is the moment that breaks things. Everything else is waiting.

Which reframes the whole job. You are not trying to change a secret. You are trying to move traffic from one credential to another, on someone else's timeline, without being able to see inside their infrastructure. The security part (a fresh random value) takes microseconds. The migration part takes days and is where all the incidents live.

Two things follow from that, and both of them contradict the popular advice.

The overlap window: two valid keys, one customer

If a key can only be replaced by invalidating it, downtime is not a risk, it is a certainty. The gap between "old key dies" and "new key deployed" is however long your customer's release process takes, and you have just made that gap into an outage.

So the first requirement is structural: your key model has to allow more than one active key per customer at the same time. If it does not, no amount of process will save you. You will be choosing between downtime and a permanently un-rotatable key, which is how keys end up seven years old.

The full sequence looks like this.

Five-phase rotation diagram: issue a second key, run both in overlap, hand over to the customer, verify the old key goes quiet, then revoke reversibly.

The examples below use ReqKey's API, because that is what I work on, but the shape transfers to anything that lets a customer hold two keys at once. In ReqKey the relevant object is the consumer: one record per customer, holding any number of keys. Keys are credentials; the consumer is the account.

Step 1: read the old key before you clone it

Skipping this is the single most common way a rotation quietly becomes a security regression. A replacement key that has broader scope than the key it replaces is a privilege escalation you shipped during a security exercise.

curl -X POST "https://api.reqkey.com/key/details" \
  -H "Authorization: Bearer reqkey_xxx..." \
  -H "Content-Type: application/json" \
  -d '{"keyId": "key_X1Y2Z3A4"}'

Expected response:

{
  "keyId": "key_X1Y2Z3A4",
  "key": "prod_A1B2C3D4E5F6G7H8I9J0K1L2",
  "consumerId": "cons_A1B2C3D4",
  "allowedApis": ["api_payment", "api_analytics"],
  "status": "active",
  "tag": "production",
  "metadata": {"environment": "prod"}
}

Note allowedApis and consumerId. You are about to reproduce the first and reuse the second.

Step 2: issue the twin

Same consumer, same scope, new value. The tag and metadata fields exist for exactly this: six weeks from now you want to know why this customer has two keys and which one is the survivor.

curl -X POST "https://api.reqkey.com/key/create" \
  -H "Authorization: Bearer reqkey_xxx..." \
  -H "Content-Type: application/json" \
  -d '{
    "consumerId": "cons_A1B2C3D4",
    "allowedApis": ["api_payment", "api_analytics"],
    "prefix": "prod",
    "tag": "rotation-jul",
    "metadata": {"replaces": "key_X1Y2Z3A4"}
  }'

Expected response (201 Created):

{
  "key": "prod_N3W4K3Y5V6A7L8U9E0H1E2R3",
  "keyId": "key_B5C6D7E8",
  "createdAt": "2026-07-24T09:12:04Z",
  "status": "active"
}

One gotcha worth knowing: if you omit allowedApis, ReqKey defaults it to ["*"], which means every API in the project. On a rotation, defaulting is the wrong behaviour. Copy the old value explicitly, even when it happens to be ["*"] anyway.

Step 3: confirm both keys are live

curl -X POST "https://api.reqkey.com/consumer/keys" \
  -H "Authorization: Bearer reqkey_xxx..." \
  -H "Content-Type: application/json" \
  -d '{"consumerId": "cons_A1B2C3D4"}'
{
  "consumerId": "cons_A1B2C3D4",
  "credits": { "limit": 10000, "remaining": 8500, "used": 1500 },
  "total": 2,
  "keys": [
    { "keyId": "key_B5C6D7E8", "status": "active", "allowedApis": ["api_payment", "api_analytics"] },
    { "keyId": "key_X1Y2Z3A4", "status": "active", "allowedApis": ["api_payment", "api_analytics"] }
  ]
}

Two active keys, one credit balance. Look at where credits sits in that response: at the top, on the consumer, not inside either key. That placement is the whole reason this rotation is boring, and it gets its own section below.

Step 4: wait for the old key to go quiet

Here is where most guides tell you to set a grace period. Seven days, thirty days, pick a number, put it in the deprecation email.

A grace period measured in days is a guess wearing a policy's clothes. The calendar does not know whether your customer deployed. Revoke on evidence instead: the old key stops receiving traffic, therefore it is safe to retire. Paddle's rotation docs make the same point from the vendor side, telling customers to check a key's last used date before revoking, and to try the new key first and keep the old one as a backup during the swap.

ReqKey does not expose a lastUsed timestamp on a key today, so the evidence comes from the traffic you are already logging. The /ingest endpoint accepts an apiKey field alongside statusCode, latencyMs and the rest, which is what gives you per-key attribution in analytics. If you are running the ingest path, "requests on key_X1Y2Z3A4 in the last 72 hours" is a chart you can already answer from.

If you are not logging per-key traffic, fix that before you plan the rotation. Revoking a credential you cannot observe is just deleting it and hoping.

Step 5: revoke, reversibly

Never make the destructive move the irreversible one. There are two sane options, and the difference matters:

  • /key/update with "status": "disabled". A flag flip. Wrong call? Flip it back.

  • /key/delete with "permanent": false. A soft delete, recoverable for 7 days. Deleting a key does not touch the consumer's credit pool.

Disable first, delete later. And put a guard in front of it, because the failure mode you care about is retiring the old key when the replacement never actually went live:

const BASE = "https://api.reqkey.com";
const ROOT_KEY = process.env.REQKEY_ROOT_KEY!;

async function reqkey(path: string, body: unknown) {
  const res = await fetch(`${BASE}${path}`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${ROOT_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify(body),
  });

  const json = await res.json().catch(() => ({}));
  if (!res.ok) {
    throw new Error(`${path} failed (${res.status}): ${json.error ?? "unknown error"}`);
  }
  return json;
}

// Refuse to retire a key unless its replacement is live on the same consumer.
async function retireOldKey(consumerId: string, oldKeyId: string, newKeyId: string) {
  const { keys } = await reqkey("/consumer/keys", { consumerId });

  const replacement = keys.find((k: { keyId: string }) => k.keyId === newKeyId);
  if (!replacement || replacement.status !== "active") {
    throw new Error(`Refusing to retire ${oldKeyId}: ${newKeyId} is not active`);
  }

  await reqkey("/key/update", { keyId: oldKeyId, status: "disabled" });
  return { retired: oldKeyId, serving: newKeyId };
}

Happy path:

{ retired: 'key_X1Y2Z3A4', serving: 'key_B5C6D7E8' }

And the case that earns the function its keep:

Error: Refusing to retire key_X1Y2Z3A4: key_B5C6D7E8 is not active

The part nobody writes about: where you attach the quota

Every rotation guide stops at "revoke the old key." None of them ask the question that decides whether rotation is a five-minute job or a support thread: what else is attached to that key?

If you meter usage, something is counting. If that counter lives on the key, you have a problem that only appears the first time you rotate:

Comparison of attaching quota to the key versus the tenant: on the key, rotation forces a balance migration; on the tenant, rotation leaves the balance untouched.

Say a customer has 8,500 credits left on a key you are about to retire. Mint the replacement and you get to choose between two bad options. Give the new key a fresh 10,000 and you just handed out 8,500 free credits, once per rotation, forever. Give it 8,500 and you have written a balance-transfer routine, which needs to be atomic, needs to handle the requests landing on the old key while it runs, and needs to be right every time or you are refunding people. Also, their usage history now lives in two places.

If your usage counter hangs off the key, every rotation is a schema migration you did not plan for. That is not a rotation problem. It is a modelling problem that rotation exposes.

Put the balance one level up, on the tenant, and the entire class of bug disappears. In ReqKey that level is the consumer: keys are auth tokens with no credit configuration of their own, and every validation deducts from the consumer's shared pool. Two keys, one balance. Rotation adds a credential and removes a credential, and the billing side never notices anything happened. The docs lay out the consumer and credit model in full, but rotation is the argument for that seam I find most convincing, because it is the one that arrives as a support ticket rather than a design review.

The general rule, whatever you are building on: credentials are disposable, accounts are not. Anything you would be sad to lose belongs on the account.

Questions you will actually hit

What happens to requests already in flight when I revoke?

A request that has already passed validation is through the gate. Revocation does not reach back and stop it, and you would not want it to.

The real question hiding inside this one is about caching. If anything between the caller and your validation layer caches an auth decision, your revocation is not effective until that cache expires. Whatever layer you validate at, find out its TTL, because that number is your actual revocation latency, not the instant the API returned 200. When someone asks "how fast can we kill a leaked key," they are asking about that TTL.

How long should the overlap window be?

Long enough for one full deploy cycle of your slowest customer, plus one full cycle of their least frequent scheduled job. It is never the web tier that breaks. The web tier redeploys twice a week. It is the monthly billing cron that has not run since before you sent the email.

If you must put a number in the deprecation notice (and you probably must, because customers ignore open-ended requests), state a date but revoke on evidence. The date creates urgency. The traffic data decides.

What should the API return once the old key is dead?

Not a bare 401. The person debugging this at 3am needs to know whether the key is wrong, retired, or out of credits, because those are three different fixes.

ReqKey's /key/validate distinguishes them for you: an unknown key comes back 200 with {"valid": false, "message": "Key not found"}, a disabled key returns 403 with "Key is disabled", and a consumer over their limit returns 402 with the remaining balance attached. What you surface to your caller is your choice, but collapsing all three into one generic error throws away the only signal that would have shortened their debugging.

Do I have to move their remaining credits?

Only if you attached them to the key. See above, and consider this your nudge to move them.

Emergency revocation is a different procedure

Everything so far assumes a planned rotation. A key committed to a public repo is not a planned rotation, and running the polite five-phase process on a leaked credential is how a leak becomes an incident.

Planned rotation versus emergency revocation: planned uses an overlap window and revokes on evidence over days; emergency is a hard cutover measured in minutes.

Aspect

Planned rotation

Emergency revocation

Trigger

Policy, offboarding, scheduled hygiene

Key leaked or abused

Overlap window

Yes, both keys valid

None

Who moves first

The customer deploys, then you revoke

You revoke, then you tell them

Timeline

Days

Minutes

Customer breakage

Unacceptable

Accepted, it is the point

ReqKey call

/key/create then /key/update with status: "disabled"

/key/update with rerollKey: true

The reroll is a hard cutover by design:

curl -X POST "https://api.reqkey.com/key/update" \
  -H "Authorization: Bearer reqkey_xxx..." \
  -H "Content-Type: application/json" \
  -d '{"keyId": "key_X1Y2Z3A4", "rerollKey": true}'
{
  "key": "prod_N3W4K3Y5V6A7L8U9E0H1E2R3",
  "status": "active"
}

New value, same keyId, same prefix, scope and metadata preserved. The old value stops working immediately.

Worth being blunt about the limitation, since it is the exact thing this article is about: ReqKey's reroll does not give you an overlap window. There is no grace period flag, no "old key valid until." That is the correct behaviour when a key is burning in public, and the wrong tool for a Tuesday-afternoon hygiene rotation. For planned work, issue a second key on the consumer and retire the first. Reroll is the fire alarm, not the maintenance schedule.

Worth twenty minutes

If you are designing the key layer itself rather than operating one, this covers the surrounding territory well: key anatomy, hashing and storage, prefixes, and where rotation and revocation fit in.

Video thumbnail: The Missing Manual for API Key Authentication, covering key design, hashing, prefixes, rotation and revocation.

For the wider lifecycle framing (issuance, monitoring, deprecation), Zuplo's lifecycle guide is a reasonable map. And if you also consume third-party keys, GitGuardian's rotation guide covers the side of the transaction this post deliberately ignores.

Closer to home, if you are building the enforcement half of the key layer rather than the lifecycle half, our walkthrough of an atomic sliding-window rate limiter in Redis and Lua is the companion piece to this one: that post is about what happens on every request, this one is about what happens on the rare day the credential changes.

Key takeaways

  • Rotation is a migration on someone else's timeline. You control the revoke, nothing else. Design the process around the waiting, not around generating a new random string.

  • Support two live keys per customer or accept guaranteed downtime. If your data model only allows one key per account, no procedure fixes it. That is a schema change, and it is the prerequisite for everything else here.

  • Revoke on evidence, not on a calendar. Announce a date to create urgency, then confirm the old key's traffic has actually gone to zero before you flip it off. If you cannot see per-key traffic, you cannot safely rotate.

  • Attach quota and usage to the account, not the key. Balances on the key turn every rotation into a transfer routine with double-grant and lost-history bugs. One level up, rotation costs nothing.

  • Keep planned and emergency paths separate. One has an overlap window and takes days; the other is a hard cutover and takes minutes. Using the wrong one is how a leak becomes an outage, or an outage becomes a leak.

Try the overlap window yourself

Everything in this post is four API calls: create a key on a consumer, list the consumer's keys, disable one, delete it. If you want to see the two-keys-one-balance behaviour without writing a key store first, ReqKey's free tier includes 100,000 requests a month at no cost, which is more than enough to run a full rotation against a test consumer and watch the credit pool stay exactly where it was. The endpoints are in the docs, the consumer and key model is on the product page, and the plan limits are on pricing.

Then go and look at how old your oldest customer key is. I will wait.

Share this post

Put your API keys on autopilot.

Keys, credits, plans, and real-time traffic analytics — free for your first 100k requests a month.