About

Why API keys are shown only once, and why we skipped the "show again" button

Store a SHA256 digest and a 12-char label. Even the system cannot recover the key

This covers how and why to show a Rails API key only once. By the end you can decide what shape the key takes in your database, and what to say when someone asks to see it again.

Short answer: they can't. If they lost it, they revoke it and issue a new one. We built it so the system itself does not know the key.

The question from code review

On a project with a public API, I added API key issuing. In review someone asked: "The list only shows the start of the key. What if I want to copy it again later?"

At first it sounded like a UX request for a copy button. Answering it pulled the whole design out with it.

Only two things go in the database

The plain key is never stored. We keep token_digest, the SHA256 hash of the key, and token_prefix, its first 12 characters. On each request we hash the incoming key again and look it up by token_digest. The prefix is just a label so people can tell keys apart. Authentication never touches it.

Issue: the raw key lives for one response onlyApiKey.issue!SecureRandom.hex(24)Raw key, 52 chars. Shown to the user once.abc_1a2b3c4d9f0e... (40 more chars)saved asapi_keys tabletoken_digest = SHA256(raw key)token_prefix = abc_1a2b3c4d (label)raw key: not stored anywhereAuth: hash again, look upAuthorization:Bearer abc_1a2b...find_by(token_digest: SHA256(given_key))The prefix on the list screen is 12 of 52 chars. Copying it gets you nothing.

The key is abc_ plus 48 hex characters, 52 in total. The abc_1a2b3c4d you see in the list is the first 12. The other 40 are not stored anywhere, so copying the prefix gets you nothing. That alone cleared up half the confusion in the review.

Why a hash and not encryption

Someone always says: encrypt it, then decrypt it when the user wants to see it. Encryption can be reversed, which means the app holds the decryption key. A hash can only be compared. It is the same reason sites make you reset a password instead of mailing it to you.

We still don't use bcrypt the way you would for passwords. Passwords are made by people, so they are guessable, and a slow hash buys time. This key is SecureRandom.hex(24), 192 random bits, so brute force is off the table. Every request has to authenticate, so a fast SHA256 is the right fit. bcrypt also salts each hash, so the same key gives a different value each time and a single find_by(token_digest:) stops working.

What a "show again" button costs

To build it you have to keep all 52 characters in a reversible form. From that moment, paths that are harmless today become leak paths.

Backups, snapshots, and copying production data into a dev environment come first. Every copy becomes a list of live keys. Anyone with a production console can read someone else's key, and since audit logs record the key owner, you can no longer tell who really did what.

Then there is all the code that sends records outside: logs, error reporting, CSV export. A digest leaking through those is fine. A plain key leaking through one of them is an incident. Encrypting instead of storing plain text only helps halfway, because anyone or anything that can read both the database and the app config can still decrypt.

The convenience you gain is seeing a lost key again. The workaround is revoke, reissue, paste the new key into your config. The math does not work out. GitHub personal access tokens and AWS secret access keys are also shown only when you create them.

What to give users instead

Stopping at "you can't" is unfriendly. So users name each key, see when it was last used, get a revoke button, and see a big copy button on the one screen right after issuing. Once the last-used time is visible, people feel safe turning off keys they don't use.

What bit us while building it

A limit of 10 active keys checked in validate breaks under concurrent requests, because another request slips in between the check and the insert. We lock the user row with with_lock so one person's issues happen one at a time.

last_used_at means an UPDATE on every request, which piles writes onto a busy key. We skip the update if it ran in the last minute, and use update_column to skip callbacks.

The one that hurt most was scopes. We had read / read_write columns and a gate on write endpoints, then removed the picker from the issue screen. Every key went out with full access, and the spec, which said users choose one of two, no longer matched the code. A scope only counts as a feature when users can pick it.

The code and a comparison of storage options are in the details below. If you are designing API keys or permissions, the kind of thing nobody notices until it goes wrong, feel free to ask negabaro to look at it with you.

How to store the key

StorageShow againSingle lookupIf the DB leaks
Plain textYesYesEvery key leaks as is
EncryptedYesOnly with deterministic encryptionSame, once the decryption key leaks too
bcryptNoNo (salted)Safe, but slow on every request
SHA256 (this post)NoYes192 random bits, cannot be reversed

Passwords made by people: bcrypt. Long random keys made by a machine: SHA256.

Issue, authenticate, revoke

TOKEN_PREFIX = "abc_"
PREFIX_DISPLAY_LENGTH = 12

scope :active, -> { where(revoked_at: nil).where("expires_at IS NULL OR expires_at > ?", Time.current) }

def self.issue!(user:, name:, scopes:)
  raw_token = TOKEN_PREFIX + SecureRandom.hex(24)
  api_key = user.with_lock do
    create!(user:, name:, scopes:,
            token_digest: digest(raw_token),
            token_prefix: raw_token[0, PREFIX_DISPLAY_LENGTH])
  end
  [api_key, raw_token]   # the plain key only leaves as a return value
end

def self.digest(raw_token) = Digest::SHA256.hexdigest(raw_token)

def self.authenticate(raw_token)
  active.find_by(token_digest: digest(raw_token))
end

def touch_last_used!
  return if last_used_at.present? && last_used_at > 1.minute.ago
  update_column(:last_used_at, Time.current)
end

API key or OAuth

A per-user API key carries that user's permissions as is, which keeps the design simple. When that person leaves or changes teams, the integration stops. If the integration belongs to the organization, look at OAuth or a service account first.

Key Points

1

On issue, build a 192-bit key with SecureRandom.hex(24) and add a service prefix (abc_)

2

Store only the SHA256 digest and a 12-char label. The plain key goes out in one response only

3

Authenticate by hashing the incoming key and calling find_by(token_digest:)

4

Instead of "show again", give names, last-used time and a revoke button

5

Use with_lock for the issue limit, a 1-minute throttle for last_used_at, and revoked_at for soft revoke

Use Cases

Adding per-user keys to a public API Answering "can I see my key again?" with the design reason
← Many services in one Rails app — why your catch-all eats the next service Owner mode without login: secret-URL auth is one key to N browsers, not 1:1 →