Thousand APIのAPIキー認証をどう設計したか|ハッシュ化・有効期限・失効処理
はじめに
MCP や SaaS の API を公開すると、クライアントは x-api-key のような共有シークレットでリクエストを送ります。キーを DynamoDB に平文のまま保存すると、DB 漏えい時に全キーが使えなくなります。失効したキーがいつまでも通る設計でも、漏えい後の被害が広がります。
個人開発の MCP サーバー Thousand API では、次の方針で API キー認証を組み立てました。
- 平文キーは発行時だけ見せる
- 保存するのは SHA-256 ハッシュのみ
- 検証は API Gateway の Lambda Authorizer に集約する
- 失効は
isActive(キーが有効か)による論理無効化
この記事では、ハッシュ化・有効性の捉え方・失効処理を、設計判断とコード例つきで整理します。タイトルの「有効期限」は、キー単体の expiresAt だけでなく、失効フラグ・利用枠・Authorizer キャッシュを含む「今このキーは使えるか」の設計を指します。
前提: TypeScript と REST API の認証の基本は分かっているが、API キーをどう永続化し、ゲートウェイ側でどう検証するかはこれから、という読者を想定しています。検証範囲: Thousand API の設計ドキュメント(共通認証ロジック・アーキテクチャ・CDK)に基づく代表パターンです。コードは内部設計のイメージであり、本番の細部やプラン枠の数値は変更され得るため、実装時は自プロダクトの正本を確認してください。
用語の短い定義です。
| 用語 | 意味 |
|---|---|
| MCP | Model Context Protocol。Cursor や Claude などが外部ツールを呼ぶ仕組み。設定ファイルに API キーを長く書き残しやすい |
| Lambda Authorizer | API Gateway の手前で認証だけを行い、Allow / Deny を返す Lambda |
| シングルテーブル設計 | DynamoDB で 1 テーブルに複数用途のレコードを PK / SK で分けて格納する設計 |
背景:API キー認証で守りたいこと
API キーはパスワードに近い共有シークレットです。OAuth のアクセストークンと違って有効期間が短いとは限らず、クライアント設定ファイルや Cursor の MCP 設定に長く残ります。したがって次を同時に満たしたいです。
| 要件 | 意図 |
|---|---|
| 秘匿性 | ストレージ漏えいだけで全キーが再取得できない |
| 検証コスト | リクエストごとに妥当なレイテンシで照合できる |
| 失効の即時性 | 漏えいや誤公開のあと、キーを止められる |
| 運用の簡単さ | ダッシュボードから発行・失効できる |
Thousand API では MCP クライアントが headers.x-api-key を送り、MCP サーバーが同じヘッダを REST API へ転送します。最終的な可否判定は API Gateway 手前の Lambda Authorizer に寄せています。
クライアント / MCP
│ x-api-key
▼
API Gateway(HTTP API)
▼
Lambda Authorizer
1. 平文キーを SHA-256 ハッシュ化
2. DynamoDB でハッシュからキー記録を逆引き
3. isActive を確認(任意でプラン・使用量も確認)
4. Allow / Deny(許可時は context に userId 等を載せる)
▼
業務 Lambda
フロー図の「プラン・使用量」は Thousand API の課金連動です。自サービスでは「キーが存在する」かつ「isActive が true」だけでも認証の最小経路になります。
1. ハッシュ化:平文を DB に置かない
なぜ SHA-256 なのか
パスワードには bcrypt や Argon2 のような遅いハッシュが一般的です。API キーとの違いは次の表で整理できます。
| 観点 | パスワード | API キー(本設計) |
|---|---|---|
| 入力の性質 | ユーザーが選ぶ短い・推測されやすい文字列があり得る | サーバーが生成する長い乱数 |
| 照合頻度 | ログイン時など比較的少ない | リクエストごとに Authorizer で照合 |
| よく使うハッシュ | bcrypt / Argon2 など遅いハッシュ | SHA-256 など速いハッシュ |
| 理由の要約 | 総当たりに耐える | 高エントロピー前提でレイテンシを優先 |
Thousand API の API キーは次の前提で扱っています。
- キー本体は十分なエントロピーのランダム文字列として発行する
- 照合はリクエストのたびに Authorizer で行う(レイテンシ制約がある)
- 逆引きはハッシュ値をパーティションキーとし、O(1) に近い検索を目指す
ランダムな高エントロピーキーに対しては、レインボーテーブル攻撃の前提がパスワードより弱いです。このため初期設計では、Node.js の crypto.createHash('sha256') でハッシュし、16 進文字列を保存する形を選びました。
// packages/shared のイメージ(hash.ts)
import { createHash, randomBytes } from 'crypto';
export const hashApiKey = (rawKey: string): string =>
createHash('sha256').update(rawKey).digest('hex');
// 発行時: 連番や短い文字列ではなく、十分な長さの乱数にする
export const generateApiKey = (): string =>
`ta_${randomBytes(32).toString('base64url')}`;
推測可能なキー(連番・短い文字列)には向きません。発行時は暗号論的に安全な乱数で十分な長さを確保してください。パスワードと同じ「ユーザー選択の弱い秘密」にはならない前提です。
DynamoDB での保存と逆引き
キー記録のイメージは次のとおりです。
export interface ApiKeyRecord {
apiKeyId: string;
userId: string;
hashedKey: string;
name: string; // 例:「本番用」
isActive: boolean; // true=利用可 / false=失効(論理無効化)
createdAt: string;
lastUsedAt?: string;
}
シングルテーブル設計では、ユーザー配下の一覧用と Authorizer 用の逆引きを分けます。METADATA は「その PK の本体レコード」を表す SK の慣習名です。
| PK / SK(例) | 用途 |
|---|---|
USER#\<userId\> / APIKEY#\<apiKeyId\> | ダッシュボードでの一覧・失効操作 |
APIKEY#\<hashedKey\> / METADATA | 平文→ハッシュ後の逆引き(認証) |
発行時は 2 種類のレコードを書くイメージです。Authorizer 側はリクエストの平文をハッシュし、APIKEY#\<hash\> を GetItem(または同等の Query)で検索します。平文そのものは DynamoDB に保存しません。
// 発行時に書く 2 レコード(イメージ)
await putItem({ PK: `USER#${userId}`, SK: `APIKEY#${apiKeyId}`, ...record });
await putItem({ PK: `APIKEY#${hashed}`, SK: 'METADATA', ...record });
// 逆引き(findApiKey のイメージ)
export const findApiKey = async (hashedKey: string) => {
const item = await getItem({
PK: `APIKEY#${hashedKey}`,
SK: 'METADATA',
});
return item as ApiKeyRecord | null;
};
アーキテクチャ上も「ユーザーへの平文表示は発行時の一度だけ」としています。
// 検証の骨格(validate.ts のイメージ)
export type AuthResult =
| { ok: true; userId: string; apiKeyId: string; plan: string }
| { ok: false; reason: 'not_found' | 'inactive' | 'limit_exceeded' };
export const validateApiKey = async (rawKey: string): Promise<AuthResult> => {
const hashed = hashApiKey(rawKey);
const keyRecord = await findApiKey(hashed);
if (!keyRecord) return { ok: false, reason: 'not_found' };
if (!keyRecord.isActive) return { ok: false, reason: 'inactive' };
// 任意: ユーザー取得・プラン上限チェック(課金がある場合)
// ...
return { ok: true, userId: keyRecord.userId, apiKeyId: keyRecord.apiKeyId, plan: '...' };
};
2. 有効性:「使える条件」を分ける(日時期限はオプション)
Thousand API の初期設計では、キー行に日時ベースの有効期限カラムを必須とはせず、次のレイヤで「今このキーは使えるか」を分けて扱いました。
| レイヤ | 何を見るか | 役割 |
|---|---|---|
| キー状態 | isActive | 人手・ダッシュボードによる即時停止 |
| 利用枠 | プランごとの月次(または日次)使用量 | 契約・課金と連動した上限(省略可) |
| Authorizer キャッシュ | 結果キャッシュの TTL | 同一キーの検証レイテンシ削減 |
日時ベースの expiresAt をキーに持たせる案もあります。チームのローテーション方針が明確なら有効です。一方、個人開発の MCP 利用では「漏れたら止める」「プラン上限に達したら止める」が先に来る場面が多く、最初は isActive と使用量チェックを優先しました。
キャッシュがあると失効が数秒遅れる
CDK 側では Lambda Authorizer の結果キャッシュを短めに設定する例があります(同一キーを数十秒キャッシュするイメージ)。失効直後でも、キャッシュが残っている間は古い Allow が返り得ます。
// CDK の HTTP Lambda Authorizer 設定イメージ
new apigwv2Auth.HttpLambdaAuthorizer('ApiKeyAuthorizer', authorizer, {
responseTypes: [apigwv2Auth.HttpLambdaResponseType.SIMPLE],
resultsCacheTtl: Duration.seconds(30),
});
体感の例です。resultsCacheTtl が 30 秒のとき、失効直後に同じキーで curl を連続すると、一部はまだ 200 になることがあります。TTL 経過後は 401(または Authorizer Deny)に揃うことがあります。秒単位で止めたい要件がある場合は、キャッシュ TTL をゼロに近づけるか、Deny 後の挙動を別途検証してください。
「有効期限」を日時で持たせる場合の最小チェック例は次のとおりです。既存の isActive チェックの直後に足せます。
if (keyRecord.expiresAt && new Date(keyRecord.expiresAt) <= new Date()) {
return { ok: false, reason: 'inactive' }; // または 'expired'
}
Thousand API 本体の初期スキーマでは必須欄にしていません。自サービスに日時期限を入れるなら、発行 UI・一覧表示・Authorizer・失効理由のログを同じ定義でそろえる必要があります。
3. 失効処理:論理無効化を Authorizer で読む
なぜ物理削除より isActive か
キーを行ごと削除すると、一覧の履歴や使用量との紐付けが追いづらくなります。isActive: false にする論理無効化なら、次がしやすいです。
- ダッシュボードに「無効」として残せる
- 同じ
apiKeyIdに紐づく使用量レコードを残せる - Authorizer は
findApiKey後に 1 フラグを見るだけでよい
// Authorizer(HTTP API の Simple レスポンス)イメージ
export const handler = async (event: {
headers?: Record<string, string | undefined>;
}) => {
const rawKey = event.headers?.['x-api-key'];
if (!rawKey) {
return { isAuthorized: false };
}
const result = await validateApiKey(rawKey);
if (!result.ok) {
return { isAuthorized: false };
}
return {
isAuthorized: true,
context: {
userId: result.userId,
apiKeyId: result.apiKeyId,
plan: result.plan,
},
};
};
拒否理由(見つからない / 無効 / 上限超過)をクライアントへ詳細返却するかはプロダクト判断です。典型パターンは次のとおりです。
- 本番向けレスポンス: いずれも同じ拒否(
isAuthorized: falseや 401)にし、攻撃者への情報を抑える - サーバーログ・開発環境:
reasonを残し、運用デバッグに使う
Thousand API の Authorizer 例では、クライアント向けは拒否にまとめ、詳細はログ側に寄せる形を想定しています。
発行と失効の運用フロー
フロント/ダッシュボード側の流れは次のイメージです。
発行:
1. バックエンドが乱数キーを生成
2. 一覧用・逆引き用の 2 レコードを保存(isActive: true)
3. 平文はレスポンスで一度だけ返す
4. 以降の一覧はプレフィックスや名前のみ
失効:
1. ユーザーがダッシュボードで「無効化」
2. isActive を false に更新(両方のレコード、または正本と同期)
3. 以降の Authorizer は inactive で Deny
4. Authorizer キャッシュ TTL 経過を待つ(遅延があり得る)
4. 失敗例:平文をそのまま保存した
初期プロトタイプで、検証を簡単にするため平文キーを属性に保存していた時期を想定します。
// やってはいけない例
await putApiKey({
apiKeyId,
userId,
rawKey: plainTextKey, // DB に平文
isActive: true,
});
この状態でスナップショットや誤ったダンプが漏れると、攻撃者はハッシュ解読を待たずに全キーを再利用できます。ハッシュ保存に切り替えたあとも、発行 API のレスポンスログやフロントの localStorage に平文を残すと、同じ穴が別経路で開きます。
対処の要点は次のとおりです。
- DB にはハッシュのみ
- 平文は発行レスポンスの 1 回限り
- アプリログに
x-api-keyを出さない - MCP 設定ファイルへの貼り付けはユーザー責任だが、サーバー側では再表示しない
学び
- 高エントロピーな API キーは、SHA-256 ハッシュ + ハッシュキーでの逆引きと相性がよい
- 「有効かどうか」は、日時期限・論理フラグ・利用枠・ゲートウェイキャッシュを分けて設計する
- 失効は
isActiveのような論理無効化を Authorizer 側で読む形が運用しやすい - 平文を一度でもストレージやログに残すと、ハッシュ化の効果が落ちる
まとめ
- API キーは平文保存せず、SHA-256 ハッシュと DynamoDB 逆引きで Authorizer 検証する
- 有効性はキーの
isActive・プラン使用量・Authorizer キャッシュ TTL を分けて考える - 失効は論理無効化を基本とし、キャッシュ TTL による猶予を設計に織り込む
次に試すこと
- テーブル定義に
rawKey/plainText相当のカラムが無いか確認する - Authorizer(または同等のミドルウェア)で「ハッシュ照合 →
isActive」の順に実装する - 失効後に同じキーでリクエストを連続し、
resultsCacheTtl経過前後の HTTP ステータスを比べる - 発行 UI で平文の再表示 API・再表示ボタンが無いことを確認する
3 の実測は、次の最小手順で試せます。
- キーを発行し、認証付きエンドポイントが 200 になることを確認する
- ダッシュボードでそのキーを失効する
- 直後から数秒おきに同じ
x-api-keyでリクエストし、いつから 401 になるかを記録する - CDK / ゲートウェイ設定の
resultsCacheTtlと実測の遅れを突き合わせる






