APIキーをバックエンドでセキュアに保持する

概要
外部APIのAPIキーを保持する、マルチテナント型SaaSを開発されている方も多数いらっしゃるかと思います。
バックエンドとしてAPIキーをどのように保持すべきか?考えてみました。
設計方針
設計方針としては、以下になるかと思います。
- シークレットは「読み取り専用」に絞る
- APIキーは書き換えや参照が頻繁に行われるものではありません。アプリケーションから必要に応じて「読む」だけでよいので、データベースやファイルに平文で置かず、読み取り専用の仕組みを用意します。
- 平文配置した瞬間に、鍵は暴かれるリスクが高まります。どれほどセキュアなサーバーでも、不慮のミスや権限の漏れはゼロにはできません。だからこそ、「読むためには真正性を示し、限定的にしか触れられない場所に格納する」ことが大前提です。
 
- テナントごとに「分離」を徹底する
- マルチテナント環境では、A社のAPIキーをB社から参照されてはいけません。
- テナントIDをベースに「名前空間」を分ける、あるいはテナントごとに別のシークレットストアを使うなど、キーの保存先を論理的・物理的に分離しましょう。
 
- 暗号化キー(KMS)は別管理
- シークレットそのものを暗号化して保存する場合、暗号化/復号の鍵(KMSキー)はアプリケーションサーバーとは別の場所で管理します。
- クラウド利用なら「AWS KMS」「Azure Key Vault」「GCP Cloud KMS」といったマネージドKMSを活用し、何があっても平文の鍵が流出しない構造を作ります。
 
- アクセス権限は「最小権限」に絞る
- アプリケーション本体(バックエンド)がシークレットを取りに行く際は、「読む権限」しか与えないIAMロール/サービスプリンシパルを用意します。
- 本番とは別の環境(ステージング・開発)を用意するなら、それぞれに異なるキーを発行し、本番のキーには一切触れられないようにします。
 
- 監査ログを有効化する
- 誰がいつ、どのテナントのどのシークレットにアクセスしたのかを必ずログに残す。
- もし不正にアクセスを試みる攻撃者がいても、ログをたどることで侵入経路や責任所在を明確にできます。
 
- 可能であれば、定期ローテーションを組み込む
- 「一度発行したAPIキーはずっと使い続ける」運用は危険です。
- 毎月または四半期に一度、(あるいはテナントの要望に応じて)自動的に新鍵に切り替える仕組みを用意しましょう。
- ローテーションといっても面倒ではありません。秘密情報の扱いを自動化し、心にゆとりを持った運用を実現しましょう。
 
2. 具体的な実装パターン例
ここでは代表的な3つのアプローチを示します。どれもメリット・デメリットがありますので、自社の運用規模やクラウドサービスの採用状況に合わせて選択してください。
2.1. クラウドシークレットマネージャ(AWS Secrets Managerなど)の活用
構成イメージ
┌───────────────────┐
│  クラウドKMS/KV   │   ← APIキーを暗号化して保管。例: AWS Secrets Manager 、
│(AWS Secrets      │      Azure Key Vault、GCP Secret Manager 等
│ Manager など)    │
└───────────────────┘
           ↑
           │(暗号化/復号リクエスト)
           │
┌───────────────────┐
│   バックエンド     │   ← シークレットを読む専用のIAMロールを付与
│  (APIサーバー等)   │
│                   │
└───────────────────┘
- テナントごとにシークレットを作成する
- 例えば AWS Secrets Manager なら、シークレット名を /saas/myapp/tenant-{tenant_id}/external-api-keyのように命名します。
- Azure Key Vault も同様に「VaultName/tenant-{tenant_id}-api-key」という形で分ければよいでしょう。
 
- 例えば AWS Secrets Manager なら、シークレット名を 
- 読み取りはSDK経由で行う
- バックエンドからはAWS SDK(Java/Python/Node.js など)や Azure SDK を通して「SecretId(名前)」を指定して暗号化済みのAPIキーを取り出す。
- SDK呼び出しの際、実行環境のInstance Profile や Service Principal に「読み取り権限」だけを与えることで、不正な書き換えや作成を防ぎます。
 
- ローテーション機能を自動化
- AWS Secrets Manager には「自動ローテーション機能」があり、Lambda関数を組み合わせれば数分で新鍵発行→置き換え→デプロイ完了までを自動化可能です。
- ローテーションの度にテナントオーナーにアラートを送りたい場合は、SNS やメール連携を活用するとよいでしょう。
 
- メリット
- マネージドサービスなのでオンプレでVaultを立てる手間が不要
- KMSキーやアクセス権周りをクラウドベンダーが管理してくれる安心感
- 監査ログやローテーション機能がデフォルトで使える
 
- デメリット・懸念点
- クラウドベンダーのサービスロックインを招きやすい
- コストがテナント数×シークレット数でかさむ可能性がある
- クラウド側の障害が直撃すると、一時的にAPIキー取得自体ができなくなるリスクがある
 
実装ポイント(AWS例)
1. IAMロール作成
   - 名前: MyApp-SecretReaderRole
   - ポリシー例 (JSON抜粋):
     {
       "Version": "2012-10-17",
       "Statement": [
         {
           "Effect": "Allow",
           "Action": [
             "secretsmanager:GetSecretValue"
           ],
           "Resource": [
             "arn:aws:secretsmanager:ap-northeast-1:123456789012:secret:/saas/myapp/tenant-*/external-api-key-*"
           ]
         }
       ]
     }
2. シークレット作成
   AWS CLI:
   $ aws secretsmanager create-secret \
       --name "/saas/myapp/tenant-1001/external-api-key" \
       --description "テナントID=1001向けの外部APIキー" \
       --secret-string '{"api_key":"YOUR_API_KEY_HERE"}'
3. バックエンドでの取得 (Node.js例)
   import AWS from 'aws-sdk';
   const secretsManager = new AWS.SecretsManager({ region: 'ap-northeast-1' });
   async function fetchApiKey(tenantId) {
     const secretId = `/saas/myapp/tenant-${tenantId}/external-api-key`;
     const data = await secretsManager.getSecretValue({ SecretId: secretId }).promise();
     const secretObj = JSON.parse(data.SecretString);
     return secretObj.api_key;
   }
2.2. HashiCorp Vault のオンプレ/Self-Hosted活用
構成イメージ
┌───────────────────┐
│  HashiCorp Vault  │   ← 自社管理のVaultクラスター。TLS必須、複数台クラスタで高可用化
│  (Vault サーバー) │
└───────────────────┘
           ↑
           │(HTTPS経由でVault APIを呼び出し)
           │
┌───────────────────┐
│   バックエンド     │   ← VaultトークンはVault上でPolicyを設定し、「読むだけ」のRole
│  (APIサーバー等)   │
│                   │
└───────────────────┘
- 各テナントごとに「パス」を分離
- Vaultのkv(Key-Valueストア)を使う場合、secret/data/tenant/{tenant_id}/api-keyのようにパスを切る。
- Policyでは、path "secret/data/tenant/${tenant_id}/*"に対して「read」権限のみを与えたトークンを発行する。
 
- Vaultのkv(Key-Valueストア)を使う場合、
- 動的シークレット(Dynamic Secrets)も活用可能
- たとえばデータベース認証情報をVaultで管理している場合、テナントごとに専用のデータベースユーザーを自動発行し、期限付きで使わせることも可能。
- 外部APIキーに対しては動的発行は難しいが、DB周りをVaultに任せることでより堅牢な設計ができます。
 
- 監査ログ・KMS連携
- VaultはAudit Deviceを有効化すれば、誰がいつどのエンドポイントに何をしたかをすべて記録します。
- バックエンドサーバー上でVault Agentを動かし、ローカルキャッシュを使ってVault呼び出しを高速化しつつ、マスターキーはHSMやクラウドKMSと連携できます。
 
- メリット
- 完全に自社管理なのでクラウドロックインがない
- Policyでのアクセス制御が細かく、必要に応じて階層的に権限を設計可能
- オンプレ・クラウド問わずデプロイできる
 
- デメリット・懸念点
- Vaultクラスタを立てて運用するための導入コストと運用負荷が高い
- TLS証明書管理やHA構成、バックアップ戦略を自前で整備しなければならない
- スケールを考えた際のノード追加やバージョンアップ時に手間がかかる
 
実装ポイント
1. Vault初期設定 (CLI例)
   $ vault server -config=/etc/vault/config.hcl   ← Vaultサーバーを起動
2. テナントごとのパスにAPIキーを書き込む
   $ vault kv put secret/tenant/1001/api-key value="YOUR_API_KEY_FOR_1001"
3. テナント専用のPolicy作成 (policy.hcl例)
   path "secret/data/tenant/1001/*" {
     capabilities = ["read"]
   }
   $ vault policy write tenant1001 policy.hcl
4. テナントトークン発行
   $ vault token create -policy="tenant1001" -ttl="72h"
   → ここで発行されたトークンをバックエンドに設定。  
     以降、バックエンドはこのトークンを使って `GET /v1/secret/data/tenant/1001/api-key` を呼び出し、  
     返ってきた暗号文をVault内で復号して「YOUR_API_KEY_FOR_1001」を取得。
5. バックエンド側コード例 (Go言語例)
   import "github.com/hashicorp/vault/api"
   func fetchApiKey(tenantID string) (string, error) {
     client, err := api.NewClient(api.DefaultConfig())
     if err != nil {
       return "", err
     }
     client.SetToken(os.Getenv("VAULT_TOKEN")) // テナント用トークンを環境変数で渡す
     secretPath := fmt.Sprintf("secret/data/tenant/%s/api-key", tenantID)
     secret, err := client.Logical().Read(secretPath)
     if err != nil {
       return "", err
     }
     data := secret.Data["data"].(map[string]interface{})
     return data["value"].(string), nil
   }
2.3. 暗号化済みDBカラム or KMSで自前実装
クラウドのマネージドサービスを使わず、自前で暗号化ロジックを組み込むパターンです。
「簡易構築」として考えるなら選択肢になりますが、長期的には保守が大変なので注意してください。
構成イメージ
┌───────────────────┐
│   KMS (自社 or    │   ← 暗号化キーのみKMS/HSMに保管。アプリからはKMS APIで復号を依頼
│ クラウドKMS)      │
└───────────────────┘
           ↑
           │(暗号化/復号リクエスト)
           │
┌───────────────────┐
│   バックエンド     │   ← テナントごとの暗号文をDBカラムに保存 (encrypted_api_key)
│  (APIサーバー等)   │
└───────────────────┘
           ↓
┌───────────────────┐
│   RDB (MySQL等)    │   ← 暗号化済みの文字列だけを持つ
│   encrypted_api_key│
└───────────────────┘
- 保存時のフロー
- テナント登録またはAPIキー登録のエンドポイントで、まずKMSに「暗号化リクエスト」を送り、平文のAPIキーをKMSが暗号化してくれる。
- 暗号化された文字列(encrypted_blob)をDBの encrypted_api_keyカラムにINSERT。
 
- 読み取り時のフロー
- DBから encrypted_api_keyカラムをSELECT して取得。
- KMSに「復号リクエスト」を送り、平文のAPIキーを取得。
- 平文APIキーはメモリ上にのみ存在し、実行が終わったらすぐ破棄する。
 
- DBから 
- メリット
- クラウドマネージドのシークレットマネージャを使わずとも、低コストで仕組みを構築できる
- RDBだけで管理できるので運用がシンプル
- テナントごとのアクセスポリシーはアプリケーションコードで柔軟に設計可能
 
- デメリット・懸念点
- 自前で暗号化ロジックを回すため、KMSのAPI設計変更や鍵管理を自社でフォローアップする負担が大きい
- 暗号化キーをどう安全に管理するかが別途の課題になる
- KMS→アプリ→DB の往復が発生するため、レスポンスが若干遅くなりやすい
 
実装ポイント(AWS KMS+MySQL例)
1. KMSキー作成
   $ aws kms create-key \
       --description "マルチテナントSaaS用外部APIキー暗号化キー" \
       --key-usage ENCRYPT_DECRYPT \
       --origin AWS_KMS
   → 応答に得られる KeyId (例: “1234abcd-12ab-34cd-56ef-1234567890ab”)
2. テナントAPIキー保存時のフロー (Node.js例)
   import AWS from 'aws-sdk';
   import mysql from 'mysql2/promise';
   const kms = new AWS.KMS({ region: 'ap-northeast-1' });
   const connection = await mysql.createConnection({ /* DB config */ });
   async function storeApiKey(tenantId, plaintextApiKey) {
     // 1) KMSで暗号化
     const enc = await kms.encrypt({
       KeyId: "1234abcd-12ab-34cd-56ef-1234567890ab",
       Plaintext: plaintextApiKey
     }).promise();
     const encryptedBlob = enc.CiphertextBlob.toString('base64');
     // 2) DBに保存
     await connection.execute(
       'INSERT INTO tenant_secrets (tenant_id, encrypted_api_key) VALUES (?, ?) ' +
       'ON DUPLICATE KEY UPDATE encrypted_api_key = VALUES(encrypted_api_key)',
       [tenantId, encryptedBlob]
     );
   }
3. テナントAPIキー読み取り時のフロー
   async function fetchApiKey(tenantId) {
     // 1) DBから暗号文を取得
     const [rows] = await connection.query(
       'SELECT encrypted_api_key FROM tenant_secrets WHERE tenant_id = ?',
       [tenantId]
     );
     if (rows.length === 0) throw new Error('APIキー未設定');
     const encryptedBlob = Buffer.from(rows[0].encrypted_api_key, 'base64');
     // 2) KMSで復号
     const dec = await kms.decrypt({
       CiphertextBlob: encryptedBlob
     }).promise();
     return dec.Plaintext.toString('utf-8');
   }
3. よくある落とし穴と回避策
- ソースコードにハードコーディングしてしまう
- 「テストだから」「一時的だから」といってコードに const API_KEY = "hogehoge";と書いてしまうと、GitHubのリポジトリを誤って公開してしまった、といった際に流出してしまいます。
- Git履歴に残ったままになると、消したつもりでも第三者に参照されるリスクがあります。
- → 絶対にやめましょう。 シークレット情報は1秒たりともコードに“べた書き”すべきではありません。
 
- 「テストだから」「一時的だから」といってコードに 
- 暗号化キーも一緒にコミットする
- 自前暗号化パターンを使う場合、「鍵ファイル」や「秘密鍵」をGitに上げるとアウト。
- → 鍵情報は専用のKMS/HSMに預け、バックエンドからのみAPI経由で利用する設計としてください。
 
- テナント間アクセスコントロールを後回しにする
- 「とりあえず同一テーブルに鍵を入れておけばいいか」と実装してしまうと、気づかぬうちにA社がB社の鍵を参照できる状態になります。
- → 早い段階で「テナントIDをベースにロジック上もDB上も分離」しておき、開発初期から「このクエリだと他社の鍵を読む恐れがある」というレビューを徹底しましょう。
 
- ローテーションを考慮せずに運用を開始する
- しばらく運用しているうちに「どの鍵がいつ更新されたか」が分からなくなり、最悪「APIキーが漏洩してから初めて更新する」という事態に陥ります。
- → ローテーション(更新)の仕組みを設計・実装してからリリースしましょう。少なくともマニュアルでもよいので、定期的に鍵を差し替える運用フローを決めておくべきです。
 
- 監査ログを軽視する
- 「うちの会社は小規模だからログなんていらない」と思っていても、実際に問題が起きた際に「誰がいつ鍵を取りにいったか」が分からなければ、対策の穴を見つけられません。
- → シークレットマネージャやVaultが提供する監査機能をオンにし、最低でも「取り出し」「更新」「削除」の全操作ログを保持しましょう。
 
結論
クラウドネイティブなシークレットマネージャを使うにせよ、Vaultを立てるにせよ、「鍵は鍵、データはデータ」 と変更頻度が異なるものを別で管理すれば、シンプルで安全な構成になります。

 
		 
		 
			 
			 
			 
			 
			
コメント