Most teams start with the same API key design: generate 32 random bytes, base64 them, put them in a keys table next to a user_id, and compare on every request. It works until the first time a key leaks, a customer asks which of their twelve keys is still in use, or someone has to rotate the key that the billing job has been using for two years.
The fix is to stop thinking of a key as a secret string and start thinking of it as a record with a lifecycle. It has an owner, a scope, a creation event, a last-used time and a way to die. Every one of those fields answers a question you will get asked during an incident.
Never store the key
You need to verify a key, not read it back. Store a SHA-256 hash of the secret and show the plaintext exactly once, at creation. If your database is copied, the attacker gets hashes of high-entropy random strings, which are useless.
Hashing creates a lookup problem, because you cannot index on a value you do not have. The usual answer is a short public prefix that carries no secret: an environment marker and a lookup id, followed by the secret part.
// hy_live_ 8-char lookup id 32-char secret
// hy_live_k7Q2mZ4p_9fJx...Lw2
const [env, id, secret] = parse(header);
const row = await keys.byId(id); // indexed, no secret involved
const ok = timingSafeEqual(sha256(secret), row.hash);The prefix pays for itself twice. Secret scanners can match hy_live_ in a public repo and alert you within minutes, and support can tell a test key from a live one without asking anyone to paste it into a chat.
Scope belongs to the key
A key that can do everything is a key you can never safely hand to a contractor, a CI job or a customer's integration. Put the scope on the record: which environment, which resources, which verbs. A reporting dashboard needs usage:read on one project, not write access to your whole account.
Narrow scopes also make leaks boring. When a read-only key for one project shows up on a paste site, the incident is a rotation, not a forensic review of everything that key could have touched.
Rotation is an overlap, not a swap
Swapping a key in place means some process somewhere is still holding the old one, and it will start failing the moment you flip. Rotation should create a second valid key, leave both live for an overlap window, and only then expire the old one.
The field that makes this safe is last_used_at. When the old key has not been seen for longer than your slowest deploy, nothing depends on it. Without that field, teams leave old keys alive forever because nobody can prove they are unused.
If you cannot answer “when was this key last used, and from where” in one query, you cannot rotate it with confidence.
Revocation has a speed
Verification usually gets cached close to the traffic, because a database round trip on every request is too slow. That cache is also how a revoked key keeps working for another ten minutes. Decide on a propagation budget and design for it. Our target in Halyard is under two seconds from the revoke call to every region rejecting the key.
What this looks like in Halyard
Every key in Halyard is created with a scope, an environment and an optional expiry. Rotation creates the successor and reports when the old key goes quiet, so revoking it is a decision you make with data instead of a guess.
const next = await halyard.keys.rotate("key_k7Q2mZ4p", {
overlap: "24h", // both keys valid for a day
expireOldWhenIdle: "1h", // then revoke once the old key goes quiet
});
console.log(next.plaintext); // shown once, never stored by usNone of this is exotic. It is the same discipline you already apply to user accounts, applied to the credentials your machines use. Treat the key as a row and most key incidents shrink to a routine change.