Back to Feed
article 3m read

API key cache had no way to hear about a revoke

A cache, most likely agent-written, stored a copy of each good key while the revoke route only knew a row ID, so a cancelled or rotated key would have kept working until its cached copy aged out, and the usage counter could run backward.

Article DepthPractice

Flow

The order I would do the work in

Each step uses something the code already has.

  1. 1Events that change a key

    Revoke, rotate, and expiry.

  2. 2Eviction by hash

    After the database update, drop the cache entry using the hash stored on the row.

  3. 3Other copies of the service

    A shorter lifetime, a shared version number, or a broadcast, with the delay written down.

  4. 4Usage count

    Send only the added amount as one database add, not a full total.

  5. 5A warm-key test

    Use a key, revoke it, use it again inside the lifetime, and expect a refusal.

Flow

How each change reaches the cache

Each fix uses something the code already holds.

  1. 1Revoke

    The route holds the row, and the row holds the hash. Drop the entry by hash after the update.

  2. 2Rotate

    Drop the old key's entry the same way. The new key needs nothing, since only keys that passed get stored.

  3. 3Expiry

    The copy checks its own expiry date and drops itself.

  4. 4Other copies of the service

    Each keeps its own cache until it expires, so they need a shorter lifetime, a shared version number, or a broadcast.

A revoked key on my telemetry service would have kept working for a few minutes after the revoke. No alarm went off. I found it by rereading the day's code changes.

One lookup per request, then a shortcut

The service takes in reports from my coding agents, and each one sends a secret key. At first, the server checked every key against the database on every request. That's slow but honest: revoke a key, and the next request is refused.

Later that day a cache went in. I think the agent wrote it, going by the summary documents that came with it. The cache is a map in memory. It's keyed by a hash of the secret, and it holds a copy of the key's database row. A copy is trusted for a few minutes. Only keys that passed get stored.

A copy can tell when its own expiry date has passed. It can't tell when someone revokes the key, because that change only lands in the database. The code does check for "revoked" on the copy, but only good keys get copied, so that check likely never trips.

Why the revoke never reached the cache

The revoke route gets a row ID. It updates the row and stops. There is a helper for dropping a key from the cache, and it's imported but never called. It also wants the original secret, which the server never keeps. The row does hold the hash, so the repair is small. Update the row first, then drop the cache entry by its hash:

row = db.mark_revoked(row_id)
cache.pop(row.fingerprint, None)

Do it in that order. Drop the entry first, and a request in between can put the old row right back.

Rotating a key has the same problem. The old key, maybe the leaked one, keeps working from cache.

The usage count can go down

The cache also batches the usage count, which is a good idea. But each flush writes a full total from an old copy, or from zero. That can replace a bigger number with a smaller one. Sending just the added amount, as a single add in the database, avoids it.

More than one copy of the service

Dropping the entry only fixes the copy of the service that handled the revoke. Any other copy keeps its own cache until it expires. A shorter lifetime, a shared version number, or a broadcast message would close that. I haven't picked one, and the right pick depends on how many copies I end up running.

Keep reading

Pick up a connected idea or branch into a nearby one.

5 paths forward