Claude CodeでAPIキーの発行とハッシュ保存・失効を実装する
自作サービスのAPIキーを発行・保存・失効する実装を、Claude Codeに任せる手順です。平文は発行時の一度きり、DBにはハッシュだけ、比較は定数時間で行います。
自分のサービスが利用者に配るAPIキーは、発行・保存・照合・失効の4つを正しく組まないと、DB漏洩がそのまま全利用者のなりすましにつながります。Claude Codeに実装を任せる場合も、仕様を先に固定して検証を回させる進め方が有効です。この記事では、平文を発行時に一度だけ見せ、DBにはハッシュだけを置き、失効とローテーションまで通す最小構成を、Node.jsの標準モジュールで示します。
ここで扱うのは、自分で作るサービス側のAPIキーです。Anthropic Consoleで作るキーの取得はClaude APIキーの取得方法、漏洩時の無効化はClaude APIキーの漏洩が疑われるときの無効化手順が対象で、設計の話は別物です。
先に決めておく仕様 — Claudeに渡す前の4項目
実装を頼む前に、次の仕様を文章で固定します。曖昧なまま「APIキー認証を作って」と頼むと、平文をDBに入れる、===で比較する、といった素朴な実装が返ってきやすくなります。
APIキー実装の仕様
形式
sk_live_<キーID>_<秘密部>の3部構成にします。プレフィックスは種別の識別、キーIDは検索用の公開値、秘密部は256ビットの乱数です。保存
DBに置くのは秘密部のハッシュだけです。平文は発行レスポンスで一度返したら二度と取り出せません。
照合
キーIDでレコードを引き、ハッシュを定数時間で比較します。
失効
削除ではなく
revoked_atを立てます。履歴が残り、誰がいつ止めたかを追えます。
プレフィックスを付ける利点は、キーを取り違えたときに見分けがつくことと、ソースコードやログの中から固定の文字列で機械的に探せることです。
発行・保存・照合の実装
発行では、crypto.randomBytesで乱数を作ります。Node.jsのドキュメントにこの関数の項目があり、暗号用途の乱数はこちらを使います。Math.random()は暗号用途に使わず、乱数はrandomBytesに統一します。
import { randomBytes, createHash, timingSafeEqual } from "node:crypto";
const PREFIX = "sk_live";
export const sha256 = (s) =>
createHash("sha256").update(s, "utf8").digest();
export function issueKey() {
const id = randomBytes(8).toString("hex");
const secret = randomBytes(32).toString("base64url");
return {
plaintext: `${PREFIX}_${id}_${secret}`,
record: { id, hash: sha256(secret), revokedAt: null },
};
}plaintextを返すのは発行APIのレスポンスだけにします。ログにもDBにも書きません。recordのhash以外の列は、後述のスキーマに入れます。
照合側は、形式の検証、IDでの検索、ハッシュの比較の順です。
export function parseKey(raw) {
const m = /^sk_live_([0-9a-f]{16})_([A-Za-z0-9_-]{43})$/.exec(raw ?? "");
return m ? { id: m[1], secret: m[2] } : null;
}
export function verifyKey(raw, find) {
const parsed = parseKey(raw);
if (!parsed) return null;
const rec = find(parsed.id);
const expected = rec ? rec.hash : Buffer.alloc(32);
const ok = timingSafeEqual(sha256(parsed.secret), expected);
return ok && rec && !rec.revokedAt ? rec : null;
}ポイントは3つあります。
timingSafeEqualは、一致するまでの時間から値を推測されないための関数です。Node.jsのドキュメントでは、HMACのダイジェストや認証クッキーのような秘密の値の比較に向くと説明されています。- 2つの引数は同じバイト長でなければならず、長さが違うと例外になります。ハッシュ同士なら常に32バイトなので、この問題は起きません。平文どうしを比べる設計だと長さ違いの入力で例外が出ます。
- キーIDが存在しない場合も、ダミーの32バイトと比較して処理時間をそろえています。ドキュメントは、この関数を使っても周辺のコードまで定時間になるわけではないと注意しています。「存在しない」と「ハッシュ不一致」で処理の重さが変わる分岐は避けます。
手元のNode.js v20.18.0で、発行、正しいキー、末尾を変えたキー、未知のID、失効後の5ケースを動かしたところ、正しいキーだけが通り、他はnullになりました。
ハッシュにSHA-256を使ってよい理由
パスワード保存の指針では、Argon2idやbcryptのような遅いハッシュを使い、SHA-256のような高速なハッシュは向かないとされています。理由は、人間が決めるパスワードは推測の範囲が狭く、高速なハッシュだと大量の候補を短時間で試せるからです。
APIキーの秘密部は、256ビットの乱数です。推測できる範囲がそもそも存在しないため、計算を遅くして守る意味がありません。そう考えると、毎リクエストで呼ばれるAPIキー照合には、SHA-256のような高速なハッシュを置くほうが運用に合います。これは上記の指針を乱数の性質に当てはめた設計判断で、パスワードに同じ方法を使ってはいけません。
乱数の長さを削る実装は、この前提を壊します。「覚えやすい短いキー」や、連番・日時から作ったキーを許す設計にすると、遅いハッシュが必要な側に戻ります。
DB漏洩に備えてさらに守りを足したいときは、パスワード保存の指針にあるpepperの考え方が使えます。ハッシュを秘密鍵でHMACして保存する方式で、鍵はDBとは別の場所に置きます。鍵を変えると既存のハッシュはすべて無効になり、利用者にキーの再発行を頼むことになります。
スキーマ、スコープ、失効
保存先のテーブルは次のとおりです。
CREATE TABLE api_keys (
id text PRIMARY KEY,
owner_id text NOT NULL,
hash bytea NOT NULL,
scopes text[] NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz,
last_used_at timestamptz,
revoked_at timestamptz
);スコープは「読むだけ」「書く」のような権限の単位を文字列で持たせます。照合に成功したら、そのリクエストが要求する権限がキーのscopesに含まれるかを別の段階で確かめます。認証(誰か)と認可(何ができるか)を1つの関数に混ぜないほうが、後から権限を足しやすくなります。
失効はrevoked_atを現在時刻にする更新だけで済みます。照合の最後でrevoked_atを見ているので、止めた瞬間から通らなくなります。last_used_atを更新しておくと、「半年使われていないキー」を棚卸しで見つけられます。
ローテーションは新旧の併存期間で組む
キーの入れ替えで利用者のサービスを止めないために、新しいキーを発行し、旧キーは期限付きで残します。
無停止でキーを入れ替える手順
- 1
新キーを発行する
同じ
owner_idでissueKey()を呼び、新しい平文を利用者に一度だけ渡します。 - 2
旧キーに期限を付ける
旧レコードの
expires_atを数日先に設定します。照合関数はexpires_atを過ぎたものを拒否するようにしておきます。 - 3
利用者が切り替える
last_used_atが新キーだけ更新されるようになったことを確かめます。 - 4
旧キーを失効させる
期限を待たず止める場合は
revoked_atを立てます。
併存期間の長さは、利用者がキーを差し替えるのにかかる時間で決めます。この数日という値に根拠となる標準はなく、サービスごとの設計事項です。
Claude Codeに任せる進め方
仕様をCLAUDE.mdに置く
プロジェクトのCLAUDE.mdには、ビルドとテストのコマンド、コーディング規約、設計上の決定を書けます。CLAUDE.mdは./CLAUDE.mdまたは./.claude/CLAUDE.mdに置きます。APIキーの設計は後から別のセッションでも守らせたいので、次のように短く書いておきます。
## APIキーの扱い
- 平文のキーはDB・ログ・例外メッセージに出さない。返すのは発行APIのみ
- 保存はSHA-256のハッシュ。比較は必ず timingSafeEqual
- 乱数は crypto.randomBytes。Math.random は使わない
- 認証失敗の応答は理由を区別しない(401、本文は固定)
- 変更後は `npm test` を実行し、出力を見せるPlan modeで設計を先にレビューする
設計の方向がずれると、DBスキーマまで書き直しになります。Plan modeでは、Claudeがファイルを読んで計画を示し、承認するまで編集をしません。ステータスバーに⏸ plan mode onと出ている間がその状態で、Shift+Tabでも切り替えられます。
Plan modeで進めて。社内向けREST APIにAPIキー認証を足す。
形式はsk_live_<id>_<secret>、DBにはsecretのSHA-256だけ保存、
比較はtimingSafeEqual、失効はrevoked_at、スコープはtext[]。
まずスキーマ・発行API・認証ミドルウェアの変更計画を出して出てきた計画で見る点は、平文がどこかに保存されていないか、比較が===になっていないか、失効済みキーの判定が入っているか、の3つです。
検証をテストで回させる
実装後は、悪い入力を先に並べたテストを書かせます。失敗するテストで再現してから直させる進め方は、ベストプラクティスのページに具体的なプロンプトの例として載っています。
verifyKeyのテストを追加して。ケースは、正しいキー・末尾1文字違い・
未知のID・失効済み・期限切れ・形式不正・スコープ不足。
npm testを実行して、出力をそのまま見せて「テストが通りました」の一言だけでは、確認したことになりません。ベストプラクティスのページも、実行したコマンドとその出力という証拠を求める進め方を勧めています。
秘密をClaudeに読ませない
開発中のAPIキーや.envをClaudeのファイルツールから遠ざけるには、Readの拒否ルールを使います。.claude/settings.jsonの例です。
{
"permissions": {
"deny": ["Read(./.env)", "Read(./secrets/**)"]
}
}拒否ルールは許可ルールより先に評価され、許可ルールで例外を作ることはできません。キーを別の保管場所へ逃がす方法は、Claude Codeのnetwork secretsにまとまっています。
実装後に確かめる3つの落とし穴
認証の応答は、理由を区別しない固定のメッセージにします。Authentication Cheat Sheetは、ユーザーIDの誤り、アカウントの不在、ロックのどれでも同じ汎用エラーを返すよう求めています。差があれば、攻撃者はアカウントの存否を探れるからです。APIキーでも、「キーIDが存在しない」と「秘密部が違う」を別メッセージにすると、有効なキーIDを探る材料になります。
処理時間の差も同じ扱いです。同じ文書は、成功と失敗で処理時間が大きく変わる実装が時間差攻撃につながる点に触れています。前節のダミー比較は、この対策の一部です。
もう1つは、総当たりの対策です。同文書には、ブルートフォースへの防御を重ねる節があります。キー照合のエンドポイントにも、IP単位やキーID単位のレート制限を置くかを決めておきます。Python製のAPIに組み込む場合の全体の流れはFastAPIバックエンドの構築手順にあります。
OpenAPIの仕様と実装のずれを機械的に洗い出したい場合は、Schemathesisで認証ヘッダ付きのリクエストを回す方法もあります。
まとめ
自作APIキーの肝は、DBに平文を置かないこと、乱数を十分に長く取ること、比較を定数時間にすることです。この3点が揃えば、遅いハッシュを使わなくても、DBが漏れただけではキーを復元されにくくなります。
Claude Codeには、仕様をCLAUDE.mdに置き、Plan modeで計画を見てから、テストの出力を読む流れで任せるのが安全です。失効は削除でなくrevoked_at、ローテーションは新旧併存、という運用側の設計まで決めておくと、キーの入れ替えが障害になりません。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。