halyard
Blog

Engineering6 min read

Your API keys are a database, not a string

A key is a row with a lifecycle: who owns it, what it can touch, when it was last used and how it dies. Model it that way and rotation stops being an incident.

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.

key format
// 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.

rotate.ts
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 us

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

Keep reading

All posts
Guides 7 min readRate limiting at the edge without lying to your usersFixed windows, token buckets and regional counters all trade accuracy for speed. Here is how we pick the trade, and why the 429 matters as much as the limit.

Ship your next API on Halyard

Free for your first million requests each month. After that you pay per million, with no gateway to run yourself. See pricing