Design API Keys That Can Be Rotated
11 May 2026A raw API key in a database column is a liability twice over: if your database leaks, every customer’s key leaks with it, and you have no way to tell which key did what before you revoke it. Design keys like passwords, because that’s what they are.
The shape of a good API key
sk_live_7f3a9b2e1c4d5f6a8b9c0d1e2f3a4b5c
│ │ └─ random secret, 32+ bytes
│ └─ environment: live / test
└─ prefix identifying the key type
The prefix is free and worth adding. Stripe popularized this and it’s now the expected convention — it lets you grep a codebase or a leaked log for sk_live_ and immediately know you found a real production secret, not a test fixture. It also lets tools like GitHub’s secret scanning recognize your key format automatically if you register it.
function generateApiKey(env = 'live') {
const secret = crypto.randomBytes(24).toString('base64url')
return `sk_${env}_${secret}`
}
Never store the raw key
Hash it like a password, using a fast hash this time — SHA-256 is fine here, unlike for passwords, because API keys are already high-entropy random strings, not human-guessable ones. There’s no brute-force risk to defend against with deliberate slowness.
function hashApiKey(key) {
return crypto.createHash('sha256').update(key).digest('hex')
}
// on creation
const rawKey = generateApiKey()
await db.apiKeys.create({
keyHash: hashApiKey(rawKey),
keyPrefix: rawKey.slice(0, 12), // for display: "sk_live_7f3a..."
userId,
})
// show rawKey to the user ONCE, never store it, never log it
return rawKey
Show the full key exactly once, at creation, in the UI. After that, only ever display the prefix — sk_live_7f3a... — enough for a user to recognize which key is which in a list, not enough to be useful if leaked from your own logs or a screenshot.
Verifying a key on each request
async function authenticateApiKey(req, res, next) {
const key = req.headers['authorization']?.replace('Bearer ', '')
if (!key) return res.status(401).json({ error: 'missing api key' })
const record = await db.apiKeys.findByHash(hashApiKey(key))
if (!record || record.revokedAt) return res.status(401).json({ error: 'invalid api key' })
db.apiKeys.touchLastUsed(record.id) // async, don't await, don't block the request
req.user = await db.users.find(record.userId)
next()
}
Scopes: don’t issue one key with full access
Even for a small API, separate read from write, and separate by resource if you have distinct ones:
const scopes = ['read:orders', 'write:orders', 'read:customers']
function requireScope(scope) {
return (req, res, next) => {
if (!req.apiKey.scopes.includes(scope)) {
return res.status(403).json({ error: `missing scope: ${scope}` })
}
next()
}
}
app.get('/orders', authenticateApiKey, requireScope('read:orders'), listOrders)
A key scoped to read:orders that leaks costs the customer far less than a key with full account access. This is worth the schema complexity even at small scale, because retrofitting scopes onto keys already in customers’ hands is a breaking migration.
Last-used tracking earns its keep at rotation time
ALTER TABLE api_keys ADD COLUMN last_used_at TIMESTAMPTZ;
When you ask customers to rotate keys (a provider breach, a scheme upgrade), last_used_at tells you which keys are actually live in someone’s production system versus abandoned test keys nobody will notice disappearing. Without it, every rotation announcement is a guess about impact.
Rotation needs a grace period, not a hard cutover
async function rotateKey(userId, oldKeyId) {
const newKey = generateApiKey()
await db.apiKeys.create({ keyHash: hashApiKey(newKey), userId })
await db.apiKeys.update(oldKeyId, { revokesAt: addDays(new Date(), 30) })
return newKey
}
Both keys work for 30 days. Killing the old key instantly the moment a new one is issued is how you turn a routine rotation into an outage for a customer’s integration they forgot to update.
The concrete next action
If you’re currently storing raw API keys in a plain column, that’s the fix to prioritize over scopes or rotation windows — a leaked database today hands out working credentials, full stop, regardless of what else you’ve built around them.