Revoked and still working: the API key revocation window
Google's deleted API keys kept working for up to 23 minutes. We measured what actually sets that window across eight independent caches, and why the number most teams would guess is half the real one.

Sorower
Co-founder

In this article
- API key revocation is a window, not an event
- We measured it, and the window is your worst cache, not your average one
- What about requests already in flight?
- So why doesn't everyone set TTL to zero?
- Should I cache rejections too?
- The same problem, wearing an OAuth costume
- Measure your own window in 40 lines
- Four levers that actually shrink the window
- What this looks like in ReqKey
- Does a shorter window mean I can stop hashing keys?
- Key takeaways
Google's console tells you a deleted key "can no longer be used to make API requests." In May 2026, Joe Leon at Aikido Security decided to check. He created standard Google API keys, deleted them, then kept sending authenticated requests until they stopped working. Across ten trials over two days, the shortest gap between deletion and the last successful call was just under 8 minutes. The longest was just under 23.
That gap has a name. It is your API key revocation window, and every provider that caches an authorization decision has one, including yours.
Google's first answer was to close the report as won't-fix, on the grounds that propagation delay is a known property of the system. A day later they reopened it as a P0. Both reactions were defensible, which is what makes this interesting. Propagation delay is a known property of anything that caches. And a dialog that says "can no longer be used" while the key works for another quarter of an hour is a P0, because the person clicking that button is usually mid-incident with a leaked key in a public repo.
So: how long does a revoked key actually keep working, why does nearly everyone estimate that number at half its real value, what does shrinking it cost, and how do you measure yours this afternoon instead of trusting a vendor page? Let's go.
API key revocation is a window, not an event
GitHub's own guidance on leaked secrets is blunt about what to do first: "The most important remediation step is revoking the secret with the secret's provider." Good advice. It is also the end of most articles on the subject, which quietly assume that revoking is a thing that happens at an instant.
It isn't. Between the moment you flip a key to revoked and the moment the last server on earth stops accepting it, there are three places delay hides:
- Validation caches. Your gateway looked this key up once and remembered the answer for a while, because doing a database round trip on every request is how you get paged.
- Replication. If your key store has read replicas, or runs in more than one region, the write has to reach them.
- Open connections and retries. A request already in flight was authorized before you clicked anything. A client with a retry loop will keep firing at whichever node still says yes.

The first of those is usually the biggest, and it is the one you control. Zuplo, to their credit, documents this trade-off directly: their API key policy takes a cacheTtlSeconds setting, defaults it to 60 seconds, and says plainly that a higher value cuts latency and database load while delaying when revocation takes effect. That is an honest description of a real dial. Most providers just don't mention the dial exists.
Which brings up the first uncomfortable truth: your revocation window is a security parameter, and on most teams it was set by whoever was tuning cache hit rates for latency. Nobody wrote it down. Nobody reviewed it. It probably still says 300.
We measured it, and the window is your worst cache, not your average one
Here is the part I could not find anywhere, so we measured it.
Setup: one authoritative key store, eight independent validation caches in front of it, each holding positive results for a configurable TTL. Ten requests per second per cache, 80 total. Each cache is warmed at a random point inside one TTL window, because a real fleet does not populate its caches in lockstep; nodes restart, deploys roll, and a given key gets its first request on each node at a different moment. Once every cache is warm, the key is revoked in the store. Then we count.
Node 26.0.0 on arm64. To be clear about what this is: eight independent caches in one process, not eight machines. It measures cache expiry timing, which is the part that sets your window. It is not a distributed systems benchmark.
| Cache TTL | Fleet window (last accepted request) | Median single cache | Requests served on a revoked key | Store lookups after revoke |
|---|---|---|---|---|
| 60s | 59.55s | 29.85s | 2,355 | 8 |
| 30s | 29.57s | 22.67s | 1,420 | 9 |
| 10s | 9.58s | 7.68s | 510 | 8 |
| 5s | 4.58s | 2.88s | 230 | 10 |
| 0s (no cache) | 0s | 0s | 0 | 152 in ~2s |
Look at the first two number columns. At a 60 second TTL, the median cache stopped accepting the key after 29.85 seconds. If you sampled one node, or ran the test once and got lucky, you would walk away believing your window is about half a minute.
The fleet kept serving that key for 59.55 seconds. Essentially the full TTL, every time, at every TTL we tried.
The reason is arithmetic rather than anything clever. Each cache holds a positive answer for a full TTL from whenever it cached. At the instant you revoke, the remaining life of each entry is spread roughly evenly across zero to TTL. The median entry has about half a TTL left. But an attacker is not using the median node. They are using whichever node still answers, and the fleet only goes quiet when the last entry expires. With eight caches, the expected maximum sits at about TTL × 8/9, and it climbs toward the full TTL as you add nodes.
So your revocation window is your cache TTL. Not half of it. Quote the whole number in your incident runbook.
The damage column follows a formula worth memorising: requests leaked ≈ nodes × rps per node × TTL / 2. At 60 seconds that predicted 2,400 requests, and we measured 2,355. Plug in your own traffic. A modest API doing 500 rps with a 60 second TTL leaks about 15,000 requests on a key you already revoked, and none of them show up as errors anywhere.

What about requests already in flight?
They complete. All of them, at every TTL including zero, because they were authorized before you touched anything. If a request takes 90 seconds to stream a large response, revocation does nothing to it. This matters more than it sounds for long-polling, streaming, and file-download endpoints, where "the key is revoked" and "the customer stopped receiving data" can be many minutes apart. If you need to kill those, you need connection-level teardown, which is a different mechanism from key validation and almost nobody builds it.
So why doesn't everyone set TTL to zero?
Because the cache is doing real work. On the same machine, we timed both paths:
- Cache hit (map lookup, expiry check, status check): 21.9 ns per validated request, median of five runs of 5 million iterations.
- Authoritative lookup over loopback HTTP, keep-alive on: 0.0427 ms p50, 0.0738 ms p95.
That is roughly a 1,900× difference, and the honest caveat is that the loopback number is a floor. It has no network, no TLS handshake, no disk, no query planner, no cross-AZ hop. Whatever your real key store costs to reach, it costs more than this. Take your actual store's p95 and add it to every single request.
The load side is starker than the latency side. At 80 rps, a 60 second TTL sent 8 lookups to the store after revocation, one per cache. TTL zero sent 152 in about two seconds. That is your store absorbing your entire request volume instead of roughly nodes / TTL lookups per second, which for many teams is the difference between one small database and a fleet of them.
This is the trade, stated plainly: every millisecond of validation latency you save by caching is bought with seconds of revocation window. That is a fine trade to make. It is a terrible trade to make by accident.
Should I cache rejections too?
Yes, but understand what you are buying. Caching negative results stops a client hammering you with a dead key from generating a store lookup per request, which is a genuine abuse control and pairs well with rate limiting failed authentication attempts. The cost is that it makes un-revocation slow. Re-enable a customer after they pay their invoice and they will sit there getting 403s for a full TTL, calling support. Use a shorter TTL for negatives than positives, and flush on re-enable.
The same problem, wearing an OAuth costume
If you have ever argued about JWTs versus token introspection, you have already had this fight without calling it by this name.
A self-contained JWT is a validation cache with a TTL, except the TTL is the token lifetime and the cache lives on every server that holds the public key. Revoking it means either a denylist that every validator must check, which is a store lookup on every request and puts you right back at TTL zero, or waiting for expiry. RFC 7662 token introspection is the other end of the same dial: ask the authorization server on every request, get immediate revocation, pay a network hop per call.
Short-lived tokens are the industry's compromise, and they are the identical bet. A 5 minute access token is a 5 minute revocation window that you have decided in advance to accept. It is a good decision. It is just not a solution to a different problem, and it is worth noticing that "we use short-lived JWTs" and "our cache TTL is 300 seconds" describe the same security posture in different vocabulary.
Worth a watch on why the industry's assumptions about API keys shifted recently:
Measure your own window in 40 lines
Every provider's marketing page says revocation is instant. None of them publish a number. The Aikido research is valuable precisely because someone bothered to run the experiment, and you can run the same experiment against any API you depend on, including ours.
Start the script, revoke the key in another window, and watch what comes back:
// revocation-probe.mjs — how long does a revoked key keep working?
// usage: node revocation-probe.mjs <url> <key>
const [url, key] = process.argv.slice(2);
if (!url || !key) {
console.error('usage: node revocation-probe.mjs <url> <key>');
process.exit(1);
}
const RPS = 5;
const started = Date.now();
let lastOk = null, firstFail = null, ok = 0, fail = 0;
const elapsed = () => ((Date.now() - started) / 1000).toFixed(1).padStart(6);
setInterval(async () => {
try {
const res = await fetch(url, { headers: { Authorization: `Bearer ${key}` } });
if (res.ok) {
ok++;
lastOk = Date.now();
if (firstFail) console.log(`${elapsed()}s ${res.status} <- still accepted AFTER a rejection`);
} else {
fail++;
if (!firstFail) {
firstFail = Date.now();
console.log(`${elapsed()}s ${res.status} <- first rejection`);
}
}
} catch (err) {
console.log(`${elapsed()}s ERR ${err.message}`);
}
}, 1000 / RPS);
process.on('SIGINT', () => {
const window = lastOk && firstFail ? (lastOk - firstFail) / 1000 : 0;
console.log(`\n${ok} accepted, ${fail} rejected`);
console.log(`last acceptance came ${window.toFixed(1)}s after the first rejection`);
console.log(window > 0
? 'Revocation is still propagating. Your window is at least this long.'
: 'Clean cutover in this run. Repeat it a few times before you trust the number.');
process.exit(0);
});
Run against a deliberately-staggered two-node test provider, it prints this:
8.0s 403 <- first rejection
8.2s 200 <- still accepted AFTER a rejection
8.6s 200 <- still accepted AFTER a rejection
9.0s 200 <- still accepted AFTER a rejection
...
12.3s 200 <- still accepted AFTER a rejection
50 accepted, 39 rejected
last acceptance came 4.2s after the first rejection
Revocation is still propagating. Your window is at least this long.
The interleaving is the whole point, and it is the single most useful thing this script tells you. Naive tests stop at the first 403 and record that as the revocation time. But a 403 followed by more 200s is the signature of partial propagation, and it matches exactly what the Google trials found: some servers rejecting the key within seconds while others kept accepting it for minutes. In one of those trials, one minute after deletion, one region was still succeeding 82% of the time while another was down to 32%.
Measure to the last acceptance, never the first rejection. Run it several times, because the number moves depending on where in the TTL cycle you happened to revoke, and take the worst run you see.
Four levers that actually shrink the window

1. Pick a TTL against an incident budget, not a latency target. The question is not "what keeps our p99 low," it is "how many minutes of a leaked key are we willing to underwrite." Write the answer in the runbook next to the number. If those two numbers disagree, you have found a real decision that nobody has made yet.
2. Push invalidation instead of waiting for expiry. A TTL is a pull mechanism: each cache discovers the revocation by forgetting. A revocation event pushed to your nodes, over a pub/sub channel or whatever your stack already has, turns a 60 second window into a sub-second one without touching your cache hit rate. This is genuinely the biggest win on this list, and it is also the one most likely to be missing, because expiry works well enough to never get prioritised.
3. Build a flush switch and test it. Push invalidation fails silently the day a node misses the message. You want a blunt "drop every cached authorization decision now" control, and you want to have used it before the incident. An untested kill switch is a comment, not a control.
4. Reach for the widest blast radius you can justify. During an incident the instinct is to revoke the one leaked key. Often the right move is to disable the whole customer, because you rarely know which of their keys is compromised. That should be a single action, not a loop over a key list you may have missed one of.
And the honest caveat on all four: none of them help if the revoked key was cached on a machine you don't operate. If a customer's SDK caches your authorization response, that is their TTL, not yours. Related reading on the mirror image of this problem, where you need the old key to keep working on purpose: API key rotation without downtime.
What this looks like in ReqKey
Being straight about it: ReqKey validates keys against an in-memory store at the edge, so like everyone else in this category we have a window, and you should measure it with the script above rather than take our word for it. What we can be concrete about is the controls, because they decide how wide the blast radius is when you use them.
Disable a single key, which fails validation with a 403 rather than a vague 401:
curl -X POST "https://api.reqkey.com/key/update" \
-H "Authorization: Bearer reqkey_xxx..." \
-H "Content-Type: application/json" \
-d '{"keyId": "key_X1Y2Z3A4", "status": "disabled"}'
After which POST /key/validate answers:
{
"valid": false,
"message": "Key is disabled"
}
Rotate in place instead, when the customer needs to keep working. rerollKey issues a new key value, keeps the same keyId, prefix, metadata and API access, and invalidates the old value at the store right away:
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"}
Or take the widest lever, which is lever 4 above as one call. A consumer's status is a master switch over every key it owns: set the consumer to disabled and all of its keys stop validating without touching any individual key's status, and setting it back to active re-enables them all. That is the control you want at 3am when you know which customer is compromised but not which of their keys.
Deleting a key is a separate action from disabling it. A soft delete sets the key's status to deleted and stays recoverable for 7 days, which exists precisely because incident-time decisions get made fast and occasionally wrong. Full request and response shapes are in the docs.
Does a shorter window mean I can stop hashing keys?
No, and the two defend against different failures. The revocation window is about how fast you can act after you know. Hashing is about what an attacker gets when they read your database, where no TTL helps you at all. If you are choosing an algorithm for that, we wrote up why bcrypt is the wrong tool for API keys. Both are cheap. Do both.
Key takeaways
- Your revocation window equals your full cache TTL, not half of it. The median cache went quiet at 29.85s on a 60s TTL, but the fleet kept accepting the key until 59.55s, because an attacker uses whichever node still answers. Quote the whole TTL.
- Estimate the damage before you pick the number.
nodes × rps per node × TTL / 2predicted 2,400 leaked requests in our run and we measured 2,355. Run your own traffic through it, then decide whether that many requests on a revoked key is acceptable. - Push invalidation is the lever nobody pulls. A shorter TTL trades revocation speed against store load on every request forever. A pushed revocation event costs nothing on the happy path and collapses the window to sub-second. Add a tested flush switch behind it.
- Measure to the last acceptance, not the first rejection. A 403 followed by more 200s is partial propagation, which is what the Google trials actually found. Run the probe several times and keep the worst result.
- During an incident, revoke wider than feels comfortable. You usually know which customer is compromised, not which key. Make "disable this customer" one action you have tested, not a loop over a list.
The uncomfortable summary is that nearly every team can tell you their p99 validation latency to one decimal place and cannot tell you their revocation window at all, despite having chosen it. Go find out what yours is. The script is 40 lines and the answer takes five minutes, which is less time than the key in that Google test stayed alive.
If you would rather not build the disable-key, reroll-key, kill-the-whole-customer set of controls yourself, that is what ReqKey is: key management, rate limiting and usage metering behind one validation call. The free plan includes 100,000 requests a month, which is plenty to point the probe above at us and get your own number before you commit to anything.



