API Keys
A long random string that identifies an application. Simple, ubiquitous, and routinely misused.
X-API-Key: sk_live_9f2c1d4a...An API key is a single opaque secret handed to a client. Present it, and the server knows which application or account is calling. It is the most common way to authenticate server-to-server traffic.
What a key is and is not
A key identifies a caller, not a person. It answers "which integration is this?" and not "which user is this?". If you need per-user identity, you need a token bound to a user — a key alone cannot express it.
Anatomy of a good key
- Prefix it.
sk_live_,sk_test_— the prefix makes leaked keys detectable by secret scanners and tells your support team what they are looking at. - At least 128 bits of entropy from a CSPRNG. Not a UUIDv4 of a user id, not a hash of the email.
- Show it once. Store only a hash in your database, exactly like a password.
- Scope it. A key for reading invoices should not be able to issue refunds.
- Make it rotatable. Support two live keys per account so customers can roll without downtime.
Hash it — with what?
Passwords use bcrypt/argon2 because humans choose weak ones and a slow hash buys time. A 256-bit random key is not brute-forceable, so a fast hash (SHA-256) is the right choice: it lets you look the key up by hash on every request without burning 100ms of CPU. Store the prefix and last four characters in plaintext so the UI can show sk_live_…9f2c.
The recurring mistakes
- In the query string. Logged everywhere. Use a header.
- In the frontend bundle. Anything shipped to a browser is public — "publishable" keys exist precisely because of this and must be safe to expose.
- Never rotated. A key from 2019 in a former employee's laptop is still a live credential.
- All-powerful. One key per integration, scoped, so a leak has a blast radius you can describe.
Example
# Generate a key with real entropy
openssl rand -base64 32 | tr -d '=+/' | cut -c1-40
# → 9f2c1d4aE7bQ2xR8vN3mK5tY6uI1oP0sD4fG7hJ2
# Ship it with a prefix so leaks are detectable
sk_live_9f2c1d4aE7bQ2xR8vN3mK5tY6uI1oP0sD4fG7hJ2
# Send it in a header
curl https://dfg.com/api/invoices -H "X-API-Key: sk_live_9f2c..."
# ❌ not like this — query strings are logged by every proxy in the path
curl "https://dfg.com/api/invoices?api_key=sk_live_9f2c..."When to use it
- A customer's backend syncs orders nightly using a scoped key that can read orders and nothing else, so a leak cannot trigger refunds.
- A payment provider issues a publishable key for the browser and a secret key for the server, with the browser key able only to tokenise a card.
- An incident response team rotates a leaked key in seconds because the account supports two active keys and the old one can be revoked after the new one is deployed.
More examples
Issue, store and verify
Because the stored value is a fast hash of a high-entropy string, you can index it and look the key up directly — no scanning every row and running bcrypt.
import { randomBytes, createHash, timingSafeEqual } from 'crypto';
const sha256 = (s) => createHash('sha256').update(s).digest();
// --- issue (shown to the user exactly once) ---
export async function createKey(accountId, scopes) {
const secret = randomBytes(32).toString('base64url'); // 256 bits
const key = `sk_live_${secret}`;
await db.apiKeys.insert({
accountId,
scopes,
hash: sha256(key).toString('hex'), // fast hash: the key is already random
prefix: key.slice(0, 12), // for the "sk_live_9f2c…" UI
last4: key.slice(-4),
createdAt: new Date(),
lastUsedAt: null,
});
return key; // never retrievable again
}
// --- verify (every request) ---
export async function apiKeyAuth(req, res, next) {
const key = req.get('x-api-key');
if (!key) return res.status(401).json({ error: 'missing_api_key' });
const row = await db.apiKeys.findByHash(sha256(key).toString('hex'));
if (!row || row.revokedAt) return res.status(401).json({ error: 'invalid_api_key' });
// Defence in depth against a hash-collision-shaped lookup bug
if (!timingSafeEqual(sha256(key), Buffer.from(row.hash, 'hex'))) {
return res.status(401).json({ error: 'invalid_api_key' });
}
req.client = { accountId: row.accountId, scopes: row.scopes };
db.apiKeys.touch(row.id).catch(() => {}); // last-used, fire and forget
next();
}Rotation without downtime
Tracking last-used per key is what turns rotation from a scary outage risk into a routine operation — it is the feature that makes the policy stick.
# 1. Customer creates a second key while the first is still live
POST /api/account/keys → sk_live_NEW...
# 2. They deploy the new key to their servers
# 3. You show them last-used timestamps so they can confirm the switch
GET /api/account/keys
[
{ "prefix": "sk_live_9f2c", "last4": "hJ2", "lastUsedAt": "2026-08-01T09:12Z" },
{ "prefix": "sk_live_a71b", "last4": "kQ8", "lastUsedAt": "2026-08-04T14:03Z" }
]
# 4. Only then is the old key revoked
DELETE /api/account/keys/sk_live_9f2c
# Without step 3 nobody dares rotate, and keys live forever.
Discussion