API keys: hashing, scoping, and showing them once
Show the key once, store a hash, keep a prefix so people can tell keys apart, and scope each one to the least it needs. Rotation should be self service, because a rotation that needs a support ticket is a rotation nobody does. If you do make a key readable again, seal it - and know what that costs.
The shape of a key
njiwa_live_7f3a9c2e... prefix + secret
^^^^ environment, so a test key in production is obvious
^^^^ stored in the clear, for identificationThe environment in the key itself is worth the characters. The most common credential incident in messaging is not a leak - it is a test key in production sending nothing, or a live key in a staging job sending everything.
What to store
| Column | Why |
|---|---|
| hash | The only copy of the secret, one-way |
| prefix and last four | So a person can identify it in a list |
| name | So they know what it was for |
| scopes | What it may do |
| created_by, created_at | Who to ask about it |
| last_used_at | So a dead key can be revoked with confidence |
| revoked_at | Never delete - the audit trail outlives the key |
last_used_at earns its keep at rotation time. Without it, revoking an unfamiliar key is a gamble, so nobody does it, so dead keys accumulate forever - each one a live credential nobody owns.
The other choice, and its price
Hashing is the default above and it is the right one. It is also not the only defensible design, and pretending otherwise is how the argument gets skipped rather than made.
What hashing costs is real: losing the one copy of a key means minting another and finding every place the old one was pasted. For a business running a single shop plugin that is an afternoon, and the people asking are the account's own owners - who can already mint a key, revoke a key, and read every conversation the key could send to. Refusing to show them a string they are entitled to create is not protecting the account from them.
What it buys is also real, and it is the whole of the trade: a database that leaks hashes leaks nothing worth having, and one that leaks recoverable keys is every customer's account. So if you go the other way, go the other way properly.
- Encrypt the key rather than storing it. AEAD - AES-256-GCM or equivalent - so a ciphertext somebody edited fails to open rather than opening to something else.
- Derive the sealing key from a secret that lives in the environment and has never been in the database. Both halves have to leak before anything opens.
- Bind the ciphertext to the account it belongs to, as the associated data. A row lifted from one account into another then does not open - not merely unreadable, unreadable there.
- Put the account owner's password in front of the reveal, rate limit it, and write every one into the audit log. A session is not evidence; a password typed a moment ago is.
This is Njiwa's own position rather than an industry consensus, which on this question does not exist. The part worth planning for is that a scheme like this does not reach backwards: keys already stored as hashes stay hashes and can never be shown, so those rows need a sentence in the interface explaining why they behave differently, forever.
Scopes that mean something
messages:send and messages:read are different powers and most integrations need one. A shop plugin that sends order updates has no business reading the inbox, and a reporting job has no business sending.
Scope by identity too where it makes sense: a key that can only send from one number limits the blast radius of a leak to that number.
Rotation, without an outage
- Create the new key.
- Deploy it alongside the old one - both valid.
- Watch
last_used_aton the old key until it stops moving. - Revoke it.
Any design that cannot do this - one key per account, no way to have two - forces an outage at every rotation, which is the reason keys stay in place for years.
Questions people actually ask
Should API keys be stored hashed?
By default, yes. A key is a credential, and storing it recoverable means a database leak is a full compromise rather than a set of useless hashes. The other way is a real choice rather than a mistake, but it is only defensible sealed: encrypted under a key held outside the database, opened behind the account owner's password, rate limited, and written to an audit log. If you cannot do all four, hash it.
How do I let customers identify a key they cannot see?
Store a short non-secret prefix and the last few characters, plus a name and a last-used timestamp. That is enough to know which key to revoke without being enough to use it.
Njiwa
One inbox for WhatsApp, Facebook and Instagram, and an API that sends over either WhatsApp rail. 7 days free, no card.