0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

外部APIキーの保存場所を評価軸で整理する

0
Last updated at Posted at 2026-05-01

とあるプロジェクトで外部APIキー(Backlog・GitHub・OpenAI等)を扱う機会があり、「ユーザーが発行したAPIキーをアプリが預かって代わりに叩く」というシナリオの設計を検討しました。

DB暗号化、KMS、Secrets Manager、localStorage、chrome.storageなど選択肢は多いですが、それぞれの攻撃シナリオとコスト・運用負荷を比較した結果を整理します。

まず何で評価するか

保存場所の善悪を論じる前に、何を軸に評価するかを決めないと比較できないので、先に並べておきます。

  • XSS exfiltration:XSSでtoken本体を持ち帰られるか(別端末・別環境での再利用)
  • XSS session riding:XSS成立時にそのまま操作を代行されるか(token不要でも被害が出るか)
  • 偶発的漏えい:ログ・APM・URL・DBダンプ・サポート画面等での漏えいリスク
  • 即時失効:tokenを今すぐ無効化できるか
  • スケール・コスト:ユーザー数が増えたときに運用・費用が現実的か
  • ユーザーごとの管理性:更新・削除・監査がユーザー単位でできるか

ひとつ注意点として、「即時失効」は「アプリ内での利用停止(DBのレコードを消す等)」と「プロバイダー側でtokenを本当に失効させる」の2つがあって別物です。漏えい済みのtokenを本当に無効化できるのは後者だけなので、ここを混同すると設計が甘くなりがちです。

大きく3つの構成がある

保存場所を考えるとき、まず「誰が外部APIを叩くか」と「キーがどこに物理的に存在するか」で構成が変わってきます。

  • ケース1:サーバー側で扱う。サーバーがキーを持って、ユーザーの代わりに外部APIを叩く
  • ケース2:HttpOnly Cookie(ステートレス暗号化)で扱う。暗号化した外部APIキーをCookieの値そのものに格納し、サーバーが復号して叩く
  • ケース3:クライアント側でキーを保持し、バックエンド経由で外部APIを叩く

サーバー側に置けるなら選択肢が広いし比較的安全ですが、クライアント側に置く場合はXSSや端末侵害時の漏えいリスクが構造的に残ります。

ケース1:サーバー側で扱う場合

サーバーが資格情報を保管するため、ブラウザや拡張機能側には露出しません。「サーバーが認証機能を持っている」かどうかは問わなくて、BFF・token brokerでもサーバーがユーザーを識別して資格情報を紐付けられるなら全部ここに入ります。

ひとつ補足しておくと、「セッションIDで識別するか・JWTで識別するか・内部ユーザーIDで引くか」はユーザーの識別手段の話であって、APIキーの保存場所の選択とは別の話です。たとえばDjangoやexpress-sessionでブラウザへHttpOnly CookieでセッションIDを渡す構成でも、APIキーはセッションストア(DB・Redis)に置かれます。保存先としては以下のパターンに帰着します。

DB暗号化保存(アプリDB)

DB暗号化保存のアーキテクチャ図

アプリのDB(RDS・PostgreSQL等)にAES-256-GCM等のAEAD(認証付き暗号化)で暗号化して保存する、一番オーソドックスな方法です。

よいところ:ユーザーごとの管理がしやすい。アプリ内での利用停止も簡単。スケールしてもコスト増が小さい。

気をつけるところ:DBダンプが漏えいしたとき、暗号化キーも同じサーバーにあると終わります。暗号化キーの管理を別で考える必要があります。

攻撃が成功するケース:DBダンプの漏洩時に暗号化キーも同じサーバーにあった場合。またはRCEでアプリプロセスを乗っ取られ、復号処理ごと使われた場合。SSRFはプロセスを直接乗っ取るわけではないですが、EC2のメタデータエンドポイントから暗号化キーを参照できるAWSクレデンシャルを取得する経路として機能します。暗号化キーの管理場所がDBと同居している構成では、ダンプ1件でユーザー全員分のキーが手に入ります。

DB + KMS envelope encryption

DB + KMS envelope encryption アーキテクチャ図

データ鍵(DEK)でtokenを暗号化し、そのDEK自体をKMSのキー(KEK)でさらに暗号化してDBに保存する方式です。復号するときはKMS APIでDEKを復号してからtokenを復号するので、KEKをアプリが長期保管しない構成になります。ただし復号時には平文DEKがメモリ上に一時的に現れます。

よいところ:DBダンプだけでは復号できない。KMS側でアクセスログが取れる。

気をつけるところ:KMS APIの呼び出しコストとレイテンシが増える。構成が複雑になる。あとKEKのローテーションは保存基盤の鍵管理の話であって、漏えい済みtokenの無効化や暗号文の自動再暗号化とは別の話です。

大量のユーザーtokenを扱う場合、後述のSecrets ManagerよりDB + KMSの方が現実的です。

攻撃が成功するケース:KMSを呼び出す権限を持つIAMロールが侵害された場合。SSRFでEC2のメタデータエンドポイントからAWSクレデンシャルを取得する攻撃や、CI/CDパイプラインからの環境変数漏洩が典型です。DBダンプだけでは止まりますが、アプリレイヤーへのRCEでKMS APIを呼べる権限ごと使われると復号されます。

Secrets Manager(AWS / GCP / HashiCorp Vault)

Secrets Manager アーキテクチャ図

AWSのSecrets Manager等、専用の秘密管理サービスに保存する方法です。

よいところ:アクセス制御・監査ログが組み込みで強い。IAMと組み合わせた最小権限管理がしやすい。

気をつけるところ:コストが高いです。AWSの場合(2026年5月時点$0.40/secret/月 + API呼び出し料金なので、10,000ユーザー × 1 secretで月約$4,000規模になります。大量ユーザーには向かないです。

それと「保存基盤としての鍵ローテーションが組み込み」なのは本当ですが、外部プロバイダーのtokenを自動rotateできるわけじゃないので、そこは別で考える必要があります。

「秘密管理ストア」であって「ユーザー索引DB」ではないので、一覧・期限切れ処理・検索のためにインデックス用DBが別途ほぼ必須になります。数十〜数百件の高価値tokenなら有力候補ですが、数千ユーザー以上はDB + KMS envelope encryptionを検討したほうが良さそうです。

実務パターンとしては、secret名を /{app}/{env}/integrations/{provider}/{tenant_id}/{subject_hash} 形式にしておくのが良い。インデックスDBで user_id → secret_arn / provider / status / expires_at を管理する構成が現実的です。

攻撃が成功するケース:IAMロールの侵害によりSecrets Manager APIを直接呼ばれた場合。SSRFでメタデータエンドポイントからクレデンシャルを取得する攻撃が典型です。DB + KMSと同様、アプリレイヤーのRCEでAPIを呼べる権限ごと使われるケースも同じです。

Lambda環境変数 / Edge runtime Secrets

Lambda環境変数 アーキテクチャ図

LambdaのKMS暗号化された環境変数や、Cloudflare WorkersのSecrets・VercelのEnvironment Variablesです。

これはユーザーごとのtoken管理には向かないです。デプロイ単位で値が決まるので、ユーザーが自分のtokenを変更するたびに再デプロイが必要になります。アプリ固有のシステム秘密(自サービスのAPIキー、DBパスワード等)向けです。ユーザーから預かったtokenの置き場所として使うのは設計ミスになります。

攻撃が成功するケース:lambda:GetFunctionConfiguration などの管理APIを呼べる権限があると、環境変数を取得されます。CloudTrailには環境変数値は記録されないため、漏えい経路としてはCloudTrailよりも、過剰なIAM権限・取得結果の二次保存・アプリのログ出力を警戒すべきです。設計上の問題として、デプロイ単位で値が固定されるため、漏洩時は「そのLambdaを使う全ユーザー分まとめて」終わります。

ケース2:HttpOnly Cookie(ステートレス暗号化)で扱う場合

HttpOnly Cookie(ステートレス暗号化)関係図

iron-session のように、外部APIキーを暗号化してCookieの値そのものに格納するステートレスな構成です。サーバーはリクエスト時にCookieを復号してキーを取り出し、外部APIを叩きます。セッションストアのようなサーバー側の状態管理が不要で、DBなしで動きます。

よいところ:JSから読めないのでXSS exfiltrationへの耐性がlocalStorageより高いです。token本体がJSに露出しません。DBなしでシンプルな構成になります。

気をつけるところ:純粋にステートレスなままでは個別セッションの即時invalidateができないです。Cookieに有効期限は設定できますが、発行済みのCookieをサーバー側から強制無効化する手段はありません。ブラックリスト等を別途用意しないと漏えい時に止める手が限られます(持つ時点でもう純ステートレスではないですが)。パスワードローテーションで全セッションをまとめて失効させることは可能ですが、特定のCookieだけを止めることはできません。暗号化パスワードが漏えいし、かつCookieの値も入手されていた場合、外部APIキーを復号されるリスクがある点はケース1のKMS管理と同じ問題意識です。

全リクエストに自動で付くambient credentialなのでCSRFリスクがあります。SameSite=Strict/Laxで緩和できますが、SameSiteだけでなく状態変更系のエンドポイントにはCSRF tokenやOrigin検証も合わせるのが基本です。Cookie削除で止まるのは「このブラウザからの自アプリへのアクセス」であって、漏えいした外部APIキー本体の失効とは別です。サイズも4KB前後の制約があります。

Chrome拡張機能との組み合わせも難しいです。拡張機能のpopupやservice workerは chrome-extension:// オリジンで動くため、自サーバーへのリクエストはクロスサイト扱いになります。credentials: 'include' に加え SameSite=None; Secure とCORSの資格情報許可が必要で、設定コストも上がります。なおcontent scriptはisolated worldで動きます。注入先ページ起点でリクエストを行う構成では、そのページ向けのCookieを送ってしまうこともあります。つまり自アプリのページ上で動いているcontent scriptからのリクエストなら、extension originではなくページ起点として扱われる余地はありますが、それも構成を選びます。素直にchrome.storage系を使うケース3-Bの方が実装しやすいです。

攻撃が成功するケース:暗号化パスワードをコードリポジトリ・ログ・環境変数から取得し、かつCookieの値もプロキシ/アプリログ/APMや安全でない通信から入手できた場合、外部APIキーを復号されます。またCSRFが成功すれば、token本体を持ち出さなくてもサーバー経由で外部APIを代行操作されます。

ケース3-A:ブラウザで保持し、バックエンド経由で叩く場合

APIキーをブラウザ側のメモリやlocalStorage等に保持し、リクエスト時に自バックエンドへ渡して、バックエンドが外部APIを呼び出す構成です。サーバーはキーを保存しませんが、キーはクライアントに存在するため、XSSや端末侵害時のexfiltrationリスクは構造的に残ります。

そのXSSについて、よく見る誤解を先に整理しておきます。

「XSS is game over」という言い方をよく見るんですが、少し雑な気がしていて。正確には「XSS成立時の被害の質は保存場所で変わる」です。

  • exfiltration(token本体の持ち帰り):localStorageやIndexedDBはJSから直接読まれてtoken本体を盗まれる。メモリはJSからの直接読み取りが難しい
  • session riding(操作代行):どのストレージでも、XSSが成立すれば被害者コンテキスト経由で対象APIを叩かれる。token本体が読めなくても攻撃者はリクエストを送れる

「localStorageを避ければXSS対策になる」は間違いで、XSS対策そのものをやることが本丸です。保存場所の選択は「被害をどこまで広げるか」の設計です。

あとログ漏えいも見落とされがちです。アクセスログ・エラーログへのtoken混入、SentryやDatadogがリクエストをキャプチャしたときの混入、DBダンプ・バックアップ、サポート画面・デバッグ表示あたりは意識しておくと良いです。特にquery parameterでAPIキーを渡すサービスはRefererやアクセスログに残るので要注意です。

メモリ(JS変数)

JSメモリ アーキテクチャ図

JavaScriptのメモリ上にtokenを持つ方法です。ページを閉じると消えます。

localStorageより持ち帰りはされにくいですが、XSSが成立した時点でJS変数を直接読まれなくても実害は出ます。fetch/XHRをhookしてAuthorizationヘッダーを抜く・既存関数の呼び出しに割り込んで操作を代行するなど、token本体を読み取らなくても攻撃できる経路があります。「メモリなら安全」と思いすぎないほうがいいです。

リロード後の復元設計が別途必要で、長寿命tokenをメモリだけで持つのは現実的でないです。

攻撃が成功するケース:XSSを成立させると、fetch/XHRをhookされて自バックエンドへのリクエストを代行され、外部APIを間接的に操作されます。token本体を直接取り出せなくても操作は成立します。ブラウザセッション中のみに限られページを閉じると継続しにくくなりますが、セッション中は攻撃者に操作を代行されます。

localStorage / sessionStorage / IndexedDB

localStorage アーキテクチャ図

XSS観点ではどれも同系統で「JSから読めるブラウザ保存」としてまとめて考えます。

実装が簡単でリロードしても消えないのは便利ですが、XSS exfiltrationが直接されます。JSから読んでtoken本体を外に持ち出せてしまうので、長寿命token・refresh tokenをここに置くのは特に危険です。マルウェアや悪意ある拡張機能でも読まれます。

sessionStorage はタブを閉じると消え、localStorage は残るという違いはあります。ただし同一タブ内でJSから読めることに変わりはないので、XSS exfiltrationのリスクは同等です。「localStorageよりましだからsessionStorageにする」という判断は、あまり意味がないです。

localStorageを選ぶこと自体を悪だと断じるのはちょっと違うと思っていて。アプリケーションの性質上XSSリスクが限りなく低いケースもありますし、XSSシナリオをしっかり潰した上でlocalStorageを選んだのであれば、それは合理的な意思決定です。大事なのは「なんとなく実装した」のか「脅威シナリオを評価した上で選んだ」かの違いです。

攻撃が成功するケース:XSSを成立させると、localStorage.getItem('api_key') 等でtoken本体を外部サーバーに送信されます。token本体が手に入ると、被害者のブラウザなしに別端末からも外部APIを叩き続けられます。マルウェアや悪意ある拡張機能がバックグラウンドで読み取るケースも同様です。

ケース3-B:Chrome拡張機能で扱う場合

chrome.storage系のAPIはChrome拡張専用で、通常のWebページからは使えません。Webページのlocalstorageとは隔離されているので、ここだけ別に考えます。

chrome.storage.local

chrome.storage.local アーキテクチャ図

デバイスにローカル保存される永続ストレージです。

拡張機能からのみアクセス可能(通常のWebページのJSからは読めない)のが利点ですが、デフォルトではcontent scriptsからも読めます。setAccessLevel({ accessLevel: 'TRUSTED_CONTEXTS' }) で隔離しておくのが無難です。マルウェアや拡張機能自体が侵害されると露出します。

攻撃が成功するケース:拡張機能のマルウェア化(サプライチェーン攻撃・悪意ある更新)時に直接読まれます。なおcontent scriptはページのXSSで注入されるものではなく拡張機能コードとして動作します。setAccessLevel で隔離していない場合、content scriptはデフォルトでストレージにアクセスできます。content scriptのメッセージハンドリングやDOM連携処理に脆弱性があると、間接的な読み取り経路になります。

chrome.storage.session

chrome.storage.session アーキテクチャ図

ブラウザ再起動・拡張更新で消えます。Manifest V3時代の拡張機能で秘密を置くなら、まずここを検討するのが良い選択肢です。

注意点として、JS変数のメモリとは異なります。MV3のservice workerはアイドルで落ちますが、chrome.storage.sessionはextensionがロードされている間は保持されます。「再起動で消える=JS変数と同じ」ではないので、そこは区別して理解しておいた方がいいです。

攻撃が成功するケース:chrome.storage.local と異なりデフォルトでcontent scriptからアクセスできないため、攻撃経路はlocalより絞られます。拡張機能本体(background/popup)の侵害が主な経路です。ブラウザ再起動で消えるため被害の継続性もlocalより低く、秘密の一時保管としては比較的リスクを絞りやすい選択肢です。

「暗号化していれば大丈夫」は間違った問いかけ

ここが個人的に一番大事だと思っているところです。

正しい問いは「その機能価値のために資格情報ストアを運営する責任を負うか」だと思っています。

サーバーに外部API資格情報を保存した時点で、自分のサービスは通常の個人情報管理ではなく、他サービスへの操作権限を集約管理する資格情報ストアになります。

暗号化が効く相手は「DBダンプ・バックアップ流出・一部運用者アクセス・ストレージ窃取」です。逆にアプリケーション侵害・RCE・SSRF・復号権限を持つワークロード侵害には決定打になりません。「暗号化しているから保存OK」ではなく「保存が必要だから、暗号化を含む多層防御を敷く」が正しい順序です。

資格情報の種類でもリスクが変わります。

  • APIキー/PAT:権限が太く、失効・スコープ制御が弱い。最もリスクが高い
  • OAuthアクセストークン:短命だが、refresh tokenが長寿命
  • GitHub App installation token等:短命・スコープ制御が強い。最もリスクが低い

保存が許容されるのは「非同期実行・定期ジョブなど、ユーザーがオフラインでも動く価値がある場合」で、かつより安全な委任モデルが使えないときです。保存するなら最小スコープ・ユーザーが失効できる手段・監査・運用者からの平文隔離・透明性はセットで必要です。

プロバイダー側の設計が上限を決める

保存場所をどれだけ頑張っても、外部サービス側の設計がセキュリティの上限を決めます。連携するサービスを選ぶときに確認しておくと良い項目です。

  • revocation endpointがあるか(tokenを失効させる手段があるか)
  • refresh token rotationがあるか
  • sender-constraining(DPoP・mTLS等)があるか
  • scope粒度が細かいか(最小権限を設定できるか)
  • APIキーがquery parameterのみか、Authorizationヘッダーが使えるか

sender-constrainingについて補足すると、OAuth系のtokenを特定のクライアントに紐づける仕組みです。Bearer tokenは持っている人が誰でも使えますが、それを防ぐのが目的です。ケース1・2ではtoken・秘密鍵をサーバー側に置けるので多層防御として機能します。ただしケース3の場合、外部APIを叩くのはバックエンドなので秘密鍵はバックエンド側に置けます。一方で生のAPIキー・PATはsender-constrainingの対象外です。ブラウザ側のAPIキーがXSSで盗まれた場合、sender-constrainingとは無関係に別端末から外部APIを直接叩かれます。

「外部APIキーを預かる」設計の前に、より安全な委任モデルが使えないか先に確認しておきたい。GitHub Appsのようなapp installationモデル・service account・delegated authなどが選択肢です。providerがbearer secretしか出さず、失効やrotationも弱い場合、その時点で運用リスクは構造的に高いです。


色々整理しましたが、銀の弾丸はない認識です。DB+KMSが常に正解で、localStorageが常に悪というわけでもなく、プロジェクトのステージやアプリケーションの性質によって、リスク許容度に応じた選択は変わります。重要なのは、「暗号化しているから保存OK」ではなく、「保存が必要だから、暗号化を含む多層防御を敷く」という順序であるべきです。脅威シナリオを正しく評価し、そのリスクを精査した上で実装を選ぶ。その過程を踏んだ選択であれば、どのケースを選んでも尊重されるべき意思決定だと考えます。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?