About

APIキーはなぜ一度しか見せないのか — 「もう一度見る」ボタンを作らなかった理由

DBにはSHA256ダイジェストと先頭12文字のラベルだけ。システムも元のキーを知らない

RailsでAPIキー発行を作るとき、「一度だけ表示」をどうやるのか、なぜそうするのかをまとめた。読み終わると、キーをDBにどんな形で入れるか、「もう一度見たい」と言われたら何と答えるかを決められる。

結論から言うと、もう一度は見られない。忘れたら失効して再発行する。システム自身がそのキーを知らないように作ってあるからだ。

レビューで受けた質問

外部公開APIを作る案件で、APIキーの発行機能を付けた。レビューでこう聞かれた。「一覧にはキーの先頭しか出ないけど、あとでもう一度コピーしたくなったらどうするんですか?」

最初はコピーボタンを1つ付ければ済むUXの話に聞こえた。ところが答えを説明していくと、設計判断が芋づる式に出てきた。

DBに残すのは2つだけ

平文のキーはどこにも保存しない。残すのはキーのSHA256ハッシュである token_digest と、先頭12文字の token_prefix だけだ。認証時は受け取ったキーをもう一度ハッシュして token_digest で引く。prefixは画面で「どのキーか」を見分けるラベルで、認証には使わない。

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.

キーは abc_ + 16進数48文字、合計52文字。一覧に見える abc_1a2b3c4d は先頭12文字の切れ端で、残り40文字はどこにもない。だからprefixをコピーしても使えない。レビューでの誤解の半分はこれで解けた。

暗号化ではなくハッシュにする理由

「暗号化して保存して、見せるときに復号すればいいのでは」と必ず言われる。暗号化は元に戻せる。つまりアプリが復号鍵を持っているということだ。ハッシュは照合しかできない。パスワードを教えてくれず再設定だけさせるサービスと同じ理屈だ。

ただしパスワードのようにbcryptは使わない。パスワードは人が作るので推測しやすく、だからわざと遅いハッシュを使う。このキーは SecureRandom.hex(24)、192ビットの乱数なので総当たりはそもそも成り立たない。リクエストのたびに認証するので速いSHA256が合っている。bcryptはソルトが入るので同じキーでも毎回違う値になり、find_by(token_digest:) 一発で引けなくなる。

「もう一度見る」ボタンの代償

ボタンを作るには52文字全体を復元できる形でDBに置く必要がある。その瞬間、今は無害な経路が全部漏えい経路になる。

まず引っかかるのがDBのバックアップやスナップショット、本番データを開発環境にコピーする作業だ。コピー1つ1つが生きたキーの一覧になる。本番コンソールの権限がある人は他人のキーを読める。監査ログはキーの持ち主の名前で残るので、誰がやったのかも曖昧になる。

ログ・エラー通知・CSVエクスポートのように、レコードが外に出ていくコードは思ったより多い。今はハッシュが混ざって出ても問題ないが、平文なら1か所のミスで漏えいだ。暗号化に変えても、DBとアプリ設定の両方を見られる人・サーバー・CIの前では結局解ける。

得られる便利さは「忘れたときにもう一度見られる」だけ。代わりの手間は、失効して再発行して新しいキーを設定に貼り直す1回。割に合わない。GitHubのpersonal access tokenもAWSのsecret access keyも、表示されるのは作成時の一度だけだ。

代わりに用意するもの

「見られません」で終わると不親切だ。そこでキーに名前を付けられるようにし、最終利用日時を出し、失効ボタンを置き、発行直後の画面にだけ大きなコピーボタンを置いた。最終利用日時が見えると、使っていないキーを安心して止められる。

作りながら踏んだもの

有効なキーは10個までという上限を validate で数えると、同時リクエストで突破される。チェックと作成の間に別のリクエストが割り込むからだ。ユーザー行を with_lock でロックし、同じ人の発行を順番に並べた。

last_used_at はリクエストごとのUPDATEなので、忙しいキー1つに書き込みが集中する。1分以内に更新済みならスキップし、update_column でコールバックを避けた。

一番痛かったのは権限スコープだ。read / read_write のカラムと書き込みゲートを作っておきながら、発行画面の選択UIを外したことがある。すると全キーが最高権限で発行され、仕様書の「2種類から選択」と実装がずれた。ユーザーが選べて初めて機能になる。

コードと保存方式の比較表は下の詳細にある。APIキーや権限のように、事故が起きるまで目立たない部分を設計しているなら、negabaro に一緒に見てほしいと声をかけてもらってかまわない。

キーをどの形で保存するか

保存方式再表示一発で引けるDBが漏れたら
平文できるできる全員のキーがそのまま漏れる
暗号化できる決定的暗号化のときだけ復号鍵も漏れれば同じ
bcryptできないできない(ソルト)安全。ただし毎リクエスト遅い
SHA256(この記事)できないできる192ビット乱数なので戻せない

人が作るパスワードならbcrypt。機械が作る長い乱数キーならSHA256。

発行・認証・失効

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]   # 平文は戻り値でしか外に出ない
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キーかOAuthか

ユーザー単位のAPIキーは「その人の権限そのまま」なので設計が単純だ。その代わり、その人が退職や異動をすると連携がまるごと止まる。連携を組織のものにしたいなら、OAuthやサービスアカウントを先に検討する。

キーポイント

1

発行時にSecureRandom.hex(24)で192ビットのキーを作り、サービス接頭辞(abc_)を付ける

2

DBにはSHA256ダイジェストと先頭12文字のラベルだけを入れる。平文はレスポンス1回にだけ載せる

3

認証は受け取ったキーを再度ハッシュしてfind_by(token_digest:)で引く

4

「もう一度見る」の代わりに名前・最終利用日時・失効ボタンを用意する

5

発行上限はwith_lock、last_used_atは1分スロットル、失効はrevoked_atの論理削除

ユースケース

外部公開APIにユーザー別のキー発行を付ける 「キーをもう一度見せて」という要望に設計の根拠で答える
← 1つのRailsアプリに複数サービス — catch-allが隣のサービスのリクエストを食べる理由 ログインなしの「本人モード」 — 秘密URL認証は1:1ではなく1:Nだ →