Designing API Keys: Generation, Storage, Rotation
API keys are the front door to your service. Most implementations get the basics wrong — and the cost is paid the first time a key leaks.
An API key is a credential. It identifies a tenant, authorizes a set of operations, and — when it leaks, which it will — becomes the most expensive string of bytes you have ever generated. Across four production APIs we have iterated on this problem until something stable emerged. The patterns are not novel, but their combination is what matters.
Generate keys with prefixes you can grep for
The first decision is format. sk_live_4f8b2d6e9a1c3704 beats a raw 32-character UUID by a wide margin, and the reason is operational: when a key leaks into a Slack message, a GitHub commit, or a screenshot, the prefix is what makes it searchable. Stripe established this convention for good reason.
Our convention: a two-character product prefix (dm_ for DocuMint, cp_ for CronPing), an environment marker (live_ or test_), and a 32-character random suffix from secrets.token_urlsafe. The leading bytes are a non-secret tag; only the random suffix needs to be cryptographically opaque. The whole string is what the client sends; the whole string is what we hash.
Hash the key on storage, never the plaintext
This is the line every API crosses sooner or later. The temptation when shipping fast is to store the key directly in the api_key column so you can show it back to the user later. Don't.
The discipline: at signup time you generate the key, return it once in the response, and store only its SHA-256 (or scrypt for very small key spaces). The user is told plainly: "You will not see this key again. Save it now." If they lose it, they generate a new one and rotate. This is exactly how SSH host keys, GitHub personal access tokens, and AWS access keys all behave.
The benefit shows up the day your database is breached or your developer accidentally SELECTs the column into a log. Without plaintext keys, the breach is recoverable; with them, every customer needs to rotate.
Store enough to identify a key without storing the key itself
You still want to show users a list of "your keys" with creation dates and a way to identify which one is which. Store the prefix (sk_live_) and the last four characters (3704) alongside the hash. That gives you sk_live_...3704 in the UI — enough to tell two keys apart, not enough to leak either one.
Add a name column the user fills in ("production", "laptop", "ci") and a last_used_at timestamp. The first matters for human management; the second is the most underrated security feature you can add — it lets users notice a key being used from a context they don't expect.
Rotation requires overlap
The naive rotation flow is "delete the old key, create a new one." This is wrong because it forces a deployment cliff: the moment you generate the new key, every running process with the old key starts failing. Real rotation needs a window where both keys work.
The pattern: support multiple active keys per tenant. Rotation is a three-step ritual — generate the new key, deploy it everywhere that uses the old one, then delete the old key. Each step is independently verifiable. The model is "keys belong to a tenant" rather than "the key is the tenant," and it changes everything downstream.
Lookups need to be fast
If you hash the key, you cannot use the hash as a primary key directly because every authenticated request needs to find the matching tenant by hash. The naive solution — SELECT * FROM api_keys WHERE hash = ? — is fine and what we use, but make sure the hash column is indexed. We learned this when a single tenant making 100 RPS pegged CPU on the auth path because the table scan was linear in the user count.
For very high throughput, an in-memory cache keyed on a short prefix of the hash gets you sub-microsecond lookups while still requiring the full hash for actual auth. Don't bother until you measure the bottleneck.
Scope keys when you can
A key with full permissions is a key that does damage when it leaks. The mature next step is scopes: a key can be marked read-only, or scoped to a single project, or rate-limited to a much lower tier. Most callers don't need full power; give them less and the blast radius drops.
This is the model of GitHub fine-grained tokens and AWS IAM. We started without it on all four products and have been retrofitting; if I were starting over I would design the auth table with a scopes column from day one even if it always read "*" initially.
Webhook signing keys are a different animal
One trap worth flagging: an inbound webhook signing secret is not the same primitive as a customer API key. Customer API keys identify the customer; webhook secrets identify a sender. Don't reuse the table or the column. We split them into api_keys and webhook_secrets early, and the conceptual clarity has prevented bugs ever since.
What it looks like in practice
Across DocuMint, CronPing, FlagBit, and WebhookVault, the auth table is six columns: id, tenant_id, key_hash, prefix, name, created_at, last_used_at. The auth middleware is forty lines: parse Authorization: Bearer ..., hash, look up, attach tenant to the request context, update last_used_at asynchronously.
None of this is exotic. It is just a discipline: never store plaintext, always hash, always show the prefix, always allow rotation overlap. The first time a customer reports a leaked key in a screenshot — and someone will, eventually — you will be glad the answer is "rotate, the breach is contained" instead of "we need to talk."