An Opinionated Approach to API Keys
- api-keys
- security
An API key is easy to treat like a random string until the first incident.
Someone leaks a database backup. Someone accidentally logs request headers. Someone from support needs to figure out which key was used for a failing request. Someone creates three keys for the same account and six months later nobody knows which one belongs to production.
None of those problems are exotic. They are normal product problems once an API has real users.
The mistake is thinking API key design is only about generating a long enough secret. Entropy matters, obviously. But the shape of the key also affects storage, logging, support, revocation and how painful the system is to operate.
The pattern I like is simple:
{public_prefix}.{secret_suffix}
Store the public prefix. Never store the full key. Store only a keyed hash of the full key.
That small structure buys a lot.
What a key has to survive
For API keys issued by our system, I usually want these properties:
- A database leak should not expose usable API keys.
- Application logs should identify which key was used without containing the secret.
- Support should be able to find a key record without asking the user to paste the full key.
- Users should only see the full key once, at creation time.
- Multiple keys per account should be normal.
- Revocation, expiry, scopes and last-used timestamps should belong to the key record.
The important bit is that API keys are bearer credentials. Whoever has the full value can use it. There is no second factor. There is no password reset flow in the middle of a request. If a key appears in a log line, Slack message, analytics event or database dump, we should assume it can be used until it is revoked.
So the storage model has to start from one rule: the full API key should not be recoverable from our database.
Everything else builds on top of that.
When the database stores the secret
The worst version is also the easiest version:
CREATE TABLE api_keys (
id UUID PRIMARY KEY,
account_id UUID NOT NULL,
key_value TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
If a request comes in, look up key_value. If support needs to find a key, search for it. If a user forgot their key, maybe show it again. Everything is simple because the database still has the secret.
That simplicity is exactly the problem.
If the database leaks, every key leaks. The attacker does not need to crack anything or guess anything. They can replay those keys directly against the API. At that point the database breach has become an authentication breach for every API consumer.
I do not think this tradeoff is defensible for issued API keys. A key is a credential. The database should remember enough to verify it, not enough to reveal it.
Hashing fixes storage but not operations
The natural fix is to hash the key:
generate random key
store sha256(key)
on request, hash the received key and compare
This is already much better. A database dump now contains hashes, not bearer credentials.
But the hash-only version is awkward to operate.
Imagine a request fails in production. You want the log line to tell you which key was involved. Logging the full key is out. Logging the hash is better, but now your logs are full of values that are only useful if someone knows they are hashes and knows where to search for them.
Support has the same problem. A user says, “my production key stopped working”. Ideally they should not have to paste the full key into a ticket. If the only identifier is the full secret or its hash, every normal support workflow becomes clunky.
Multiple keys per account make it worse. A user may have one key for production, one for staging and one for an internal service. A name field helps, but names drift. The key itself should carry a small non-secret handle that both humans and systems can use.
That is where the prefix helps.
Giving the key a public handle
The full key has two parts:
ak_live_7hG9pQ2mLx4r.BZJqf4YoT8zYbWyvld9SgGk4p2XnUQ1Wk5mFvEo3cRA
The first part is the public prefix:
ak_live_7hG9pQ2mLx4r
The second part is the secret suffix:
BZJqf4YoT8zYbWyvld9SgGk4p2XnUQ1Wk5mFvEo3cRA
The prefix is is just an identifier. It is not a permission, neither it is proof of anything.
I like having a small human-readable marker in the prefix, such as ak_live or ak_test, followed by a random identifier. The marker helps people avoid obvious mistakes, like using a test key in production. The random part makes the prefix unique enough to use in logs and database lookups.
The suffix is the actual secret. Generate it with a cryptographically secure random number generator. I usually want at least 32 random bytes encoded as base64url or another URL-safe alphabet. Character count by itself is not the security property; rather it is entropy that defines how random your key is.
The delimiter is boring. A dot is fine. The only thing I care about is that parsing is unambiguous and validation is strict.
What lands in the table
The database stores the prefix and a hash of the full key:
CREATE TABLE api_keys (
id UUID PRIMARY KEY,
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
name TEXT NOT NULL,
key_prefix TEXT NOT NULL UNIQUE,
key_hash BYTEA NOT NULL UNIQUE,
hash_version INTEGER NOT NULL DEFAULT 1,
scopes TEXT[] NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_used_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ
);
CREATE INDEX idx_api_keys_account
ON api_keys (account_id);
CREATE INDEX idx_api_keys_active_account
ON api_keys (account_id)
WHERE revoked_at IS NULL;
There are a few choices hidden in this table.
key_prefixis stored in plain text because it is not a secret. It is the thing we are allowed to put in logs, dashboards and support tools.key_hashis the verification value. I prefer an HMAC, for example HMAC-SHA-256 over the full key using a server-side secret:
key_hash = hmac_sha256(api_key_hash_secret, full_key)
Plain SHA-256 over a high-entropy server-generated key is already a different world from hashing user passwords. API keys should be random enough that offline guessing is not realistic. But a keyed hash gives one more useful property: a database-only leak does not even give the attacker raw hashes they can test without also having the application secret.
The hash_version column is there because secrets and algorithms age. Maybe we rotate the HMAC secret later. Maybe we change the encoding. Having a version field keeps that migration from becoming weird.
The other columns are not decoration. API keys usually need lifecycle:
namelets the user call a keyprod backendorstaging worker.scopeslimit what the key can do.last_used_athelps users and support identify dead keys.expires_atallows temporary credentials.revoked_atgives revocation a boring database shape.
I would not make account_id unique. Multiple keys per account should be normal.
Show it once
When creating a key, the flow is:
generate random prefix id
generate random secret suffix
full_key = prefix + "." + suffix
key_hash = hmac_sha256(api_key_hash_secret, full_key)
store key_prefix and key_hash
show full_key to the user once
That last line matters.
After creation, the system cannot show the full key again because it does not have it. That is not a missing feature. That is the point. The UI should say this plainly when the key is created: copy it now, store it somewhere safe, you will not be able to see it again.
If the user loses the key, they create a new one and revoke the old one.
I also avoid UUIDs as the main secret. A UUID can be useful as a database identifier, but for credentials I want random bytes from a CSPRNG and an encoding chosen for copy-paste safety.
Authenticating without revealing
The request should send the key in a header:
Authorization: Bearer ak_live_7hG9pQ2mLx4r.BZJqf4YoT8zYbWyvld9SgGk4p2XnUQ1Wk5mFvEo3cRA
I avoid query parameters for API keys. They leak too easily into access logs, browser history, caches, analytics systems and error reports. Headers are not magic, but they are the right default.
The authentication flow is:
read Authorization header
parse the key into prefix and suffix
validate the format
compute HMAC over the full received key
load the key record by prefix
compare the stored hash with the computed hash
check revoked_at, expires_at and scopes
attach account_id and key_id to the request context
The lookup can be boring:
SELECT *
FROM api_keys
WHERE key_prefix = $1;
Then compare the stored hash with the computed hash using a constant-time comparison.
You can also query by both prefix and hash:
SELECT *
FROM api_keys
WHERE key_prefix = $1
AND key_hash = $2;
I still like keeping the comparison rule explicit in application code, because this is credential verification and I want the code to read like it.
Regardless of which query shape you choose, invalid key responses should be boring. Do not tell the client whether the prefix exists, whether the suffix was wrong, whether the key expired or whether it was revoked. Return the same 401 shape. Put the useful detail in internal logs.
Logging
This is where the prefix pays rent.
When a request comes in, the logs can include:
api_key_prefix=ak_live_7hG9pQ2mLx4r
After authentication succeeds, logs can include richer internal identifiers:
account_id=acc_123
api_key_id=key_456
api_key_prefix=ak_live_7hG9pQ2mLx4r
What should never appear is the suffix or the full header value.
That sounds obvious, but it is worth making boring and automatic. Redact Authorization at the logging middleware layer. Redact it in exception reporting. Redact it in HTTP client debugging. Redact it before structured logs leave the process.
The prefix gives you enough to debug without turning logs into a credential store.
If support sees a failing request with api_key_prefix=ak_live_7hG9pQ2mLx4r, they can find the key record immediately. They can check whether it belongs to the right account, when it was last used, whether it expired, whether it was revoked and what scopes it has.
Nobody has to ask the user for the secret.
What this does not solve
This pattern protects against some very specific classes of mistakes. It does not make API keys harmless.
If a user commits the full key to GitHub, the key is compromised. The right response is still revoke and rotate.
If an attacker gets application access and can read the HMAC secret plus the database, the hash no longer buys much. At that point you are dealing with a larger compromise.
If a key has broad scopes and no expiry, the blast radius is broad. Prefixes and hashes do not fix authorization design.
If you accept API keys in query parameters, they will eventually show up somewhere annoying.
So I still want the surrounding controls:
- scoped keys by default
- easy revocation
- clear creation and last-used timestamps
- optional expiry
- rate limits per key or account
- secret scanning for common key prefixes
- audit events for creation, revocation and scope changes
The key format is only one layer. It just happens to be a layer that makes the rest easier.
The shape I keep coming back to
The useful way to think about an API key is not just “a long random string”. It is two things at once:
- a public handle the system can safely talk about
- a secret bearer credential the system must never reveal
Putting those two ideas into the key shape makes the implementation cleaner.