APIキーはなぜ一度しか見せないのか — 「もう一度見る」ボタンを作らなかった理由
DBにはSHA256ダイジェストと先頭12文字のラベルだけ。システムも元のキーを知らない
RailsでAPIキー発行を作るとき、「一度だけ表示」をどうやるのか、なぜそうするのかをまとめた。読み終わると、キーをDBにどんな形で入れるか、「もう一度見たい」と言われたら何と答えるかを決められる。
結論から言うと、もう一度は見られない。忘れたら失効して再発行する。システム自身がそのキーを知らないように作ってあるからだ。
レビューで受けた質問
外部公開APIを作る案件で、APIキーの発行機能を付けた。レビューでこう聞かれた。「一覧にはキーの先頭しか出ないけど、あとでもう一度コピーしたくなったらどうするんですか?」
最初はコピーボタンを1つ付ければ済むUXの話に聞こえた。ところが答えを説明していくと、設計判断が芋づる式に出てきた。
DBに残すのは2つだけ
平文のキーはどこにも保存しない。残すのはキーのSHA256ハッシュである token_digest と、先頭12文字の token_prefix だけだ。認証時は受け取ったキーをもう一度ハッシュして token_digest で引く。prefixは画面で「どのキーか」を見分けるラベルで、認証には使わない。
キーは 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やサービスアカウントを先に検討する。
キーポイント
発行時にSecureRandom.hex(24)で192ビットのキーを作り、サービス接頭辞(abc_)を付ける
DBにはSHA256ダイジェストと先頭12文字のラベルだけを入れる。平文はレスポンス1回にだけ載せる
認証は受け取ったキーを再度ハッシュしてfind_by(token_digest:)で引く
「もう一度見る」の代わりに名前・最終利用日時・失効ボタンを用意する
発行上限はwith_lock、last_used_atは1分スロットル、失効はrevoked_atの論理削除