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)に基づく代表パターンです。コードは内部設計のイメージであり、本番の細部やプラン枠の数値は変更され得るため、実装時は自プロダクトの正本を確認してください。

用語の短い定義です。

用語意味
MCPModel Context Protocol。Cursor や Claude などが外部ツールを呼ぶ仕組み。設定ファイルに API キーを長く書き残しやすい
Lambda AuthorizerAPI 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 による猶予を設計に織り込む

次に試すこと

  1. テーブル定義に rawKey / plainText 相当のカラムが無いか確認する
  2. Authorizer(または同等のミドルウェア)で「ハッシュ照合 → isActive」の順に実装する
  3. 失効後に同じキーでリクエストを連続し、resultsCacheTtl 経過前後の HTTP ステータスを比べる
  4. 発行 UI で平文の再表示 API・再表示ボタンが無いことを確認する

3 の実測は、次の最小手順で試せます。

  1. キーを発行し、認証付きエンドポイントが 200 になることを確認する
  2. ダッシュボードでそのキーを失効する
  3. 直後から数秒おきに同じ x-api-key でリクエストし、いつから 401 になるかを記録する
  4. CDK / ゲートウェイ設定の resultsCacheTtl と実測の遅れを突き合わせる

Source