ウェブアプリがブラウザから直接OpenAI、Claude、Geminiを呼び出している場合、APIキーがブラウザ内で露出しており、DevToolsを開く誰でもそれをコピーできます。2分で確認できます。Networkタブを開き、AIリクエストを1つ送信し、プロバイダーに送信される Authorization、x-api-key、または x-goog-api-key ヘッダーを探します。そこにキーが見つかったら、今日中にローテーションし、呼び出しをサーバールートの背後に移動してください。
以下では、チェック方法、修正の順序、そしてコーディングエージェントが修正済みルートをテストする方法を説明します。
なぜブラウザで露出したAPIキーが問題なのか
ブラウザで露出したAPIキーは、フロントエンドコードやネットワークトラフィック内に配布される秘密情報であり、すべての訪問者がそれを読むことができます。ブラウザがキーを送信すると、ユーザーはそれをコピーし、あなたの請求でプロバイダーを呼び出すことができます。
GoogleのGemini APIキーガイドは、「APIキーをWebアプリやモバイルアプリに直接ハードコードしないでください」と述べています。なぜなら、「クライアントサイドコードにコンパイルされたキーはユーザーによって抽出される可能性がある」からです。また、「実際のAPI呼び出しを行うにはバックエンドプロキシサーバーを実行してください」と指示しています。OpenAIのAPIキー安全性ガイドも同じルールを示しています。キーをクライアントにデプロイするのではなく、リクエストを独自のバックエンド経由でルーティングしてください。
openai-node READMEには、ブラウザサポートは「秘密のAPI認証情報の露出を避けるため」デフォルトで無効になっており、有効にするにはdangerouslyAllowBrowser: trueを設定する必要があると書かれています。AnthropicのTypeScript SDKも同じオプション名を使用しており、そのAPIはリクエストがanthropic-dangerous-direct-browser-access: trueヘッダーを運ぶ場合にのみブラウザ(CORS)呼び出しを受け入れます。Simon Willisonが記録したように、出荷時の仕様です。
このパターンは、AIアプリビルダーやコーディングエージェントで素早く構築されたアプリでよく見られます。最短の動作パスは、キーをVITE_またはNEXT_PUBLIC_変数に入れたReactコンポーネントからのfetchです。盗まれたキーはその後、一括推論のために転売されます。これは盗まれたAI APIキーとLLMjackingに関する記事で取り上げています。そして、あなたが気づくのは請求書です。
APIキーが露出しているか確認する方法(2分)

これらのチェックはlocalhostではなく本番環境に対して実行してください。
- Networkタブを監視する(45秒)。 サイトを開き、F12(MacではCmd+Option+I)を押し、Networkタブを選択して、チャットメッセージの送信など、AI機能を1つトリガーします。
openai、anthropic、またはgoogleapisでフィルタリングします。リクエストがapi.openai.com、api.anthropic.com、またはgenerativelanguage.googleapis.comに直接送信される場合、それをクリックしてHeadersを開きます。Authorization: Bearer、x-api-key、x-goog-api-key、または?key=クエリパラメータにキーがあれば、露出しています。 - バンドルを検索する(45秒)。 DevToolsでCmd+Option+F(WindowsではCtrl+Shift+F)を押して、読み込まれたすべてのスクリプトを検索します。
sk-、sk-ant-、AIza、dangerouslyAllowBrowserを検索します。ブラウザに提供される.jsファイルにヒットがあれば、まだリクエストが発火していなくてもリークです。 - 環境変数のプレフィックスを確認する(30秒)。
.envファイルとホスティングダッシュボードを開きます。ビルドツールは設計上、一部の変数をクライアントバンドルにコピーします。ViteではVITE_*(Vite env docsを参照)、Next.jsではNEXT_PUBLIC_*、Create React AppではREACT_APP_*、ExpoではEXPO_PUBLIC_*です。これらのプレフィックスのいずれかの背後にあるプロバイダーキーは、どのように命名されていても公開されます。
ビルド出力をターミナルでスキャンするには:
# from your project root after a production build
grep -rnoE "sk-(proj-|ant-)?[A-Za-z0-9_-]{20,}|AIza[0-9A-Za-z_-]{35}" dist/ build/ .next/static/ 2>/dev/null
grep -rn "dangerouslyAllowBrowser" dist/ build/ .next/static/ 2>/dev/null
出力が空であれば合格です。Googleによると、2026年5月28日以降に作成された新しいAI Studioキーは「認証キー」であり、その形式はAIzaと異なる可能性があるため、バンドル内でgenerativelanguage.googleapis.comも検索してください。ブラウザが自分のドメイン(例えば/api/chat)と通信し、プロバイダーのホスト名やキーがどこにも表示されない場合にのみ合格です。
キーが露出したときの対処:修正の順序
まずローテーションすることで今日の穴を塞ぎ、アーキテクチャの変更でそれを恒久的に塞ぎます。
| ステップ | やること | この順序の理由 | 時間 |
|---|---|---|---|
| 1. キーをローテーションする | プロバイダーコンソールで新しいキーを作成し、漏洩したキーを無効化する | 古いキーはすでにコピーされている可能性があり、無効化だけがその使用を止める | 5分 |
| 2. 呼び出しをサーバーサイドに移動する | プロバイダー呼び出しを、キーを保持するサーバールートまたはエッジ関数に配置する | 新しいキーが同じように漏洩するのを防ぐ | 30〜60分 |
| 3. 認証とユーザーごとのレート制限を追加する | ルートでセッションを必須にし、ユーザーごとのリクエストを制限する | これがないと、プロキシがあらゆる人にとって無料のAIエンドポイントになる | 30分 |
| 4. プロバイダーの支出制限を設定する | 各プロバイダーの請求設定で予算または支出制限を設定する | 他に何かが漏洩した場合の最悪の請求額を上限で抑える | 10分 |
| 5. CIでスキャンする | 出力バンドルにキーパターンが現れたらビルドを失敗させる | 次にエージェントが書く機能が修正を元に戻すのを防ぐ | 15分 |
古いキーを無効化する前に、新しいキーをサーバーサイドにデプロイしてください。そうしないとAI機能が停止します。APIキーローテーションのベストプラクティスに関するガイドでは、ゼロダウンタイムのローテーションについて説明しています。ステップ4について、OpenAIのキー安全性ガイダンスは、アラートだけではトラフィックを止めないため、強制力のある支出制限を推奨しています。Google Cloudの請求予算はアラートを出しますが、それ自体では使用を停止しません。
OpenAI、Claude、Geminiの呼び出しをサーバールートの背後に移動する方法

バックエンドプロキシルートは、ユーザーのプロンプトを受け取り、サーバー上でAPIキーを追加し、プロバイダーを呼び出して回答のみを返す小さなサーバーエンドポイントです。ブラウザはキーを決して見ません。以下はOpenAI用の最小限のNext.jsルートです。ClaudeとGeminiも同じ形に従います。
// app/api/chat/route.ts (runs on the server only)
import OpenAI from "openai";
import { getSession } from "@/lib/auth"; // your auth helper
import { checkRateLimit } from "@/lib/limits"; // your per-user limiter
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); // no NEXT_PUBLIC_ prefix
export async function POST(req: Request) {
const session = await getSession(req);
if (!session) return new Response("Unauthorized", { status: 401 });
if (!(await checkRateLimit(session.userId, 20))) { // 20 requests per minute
return new Response("Too many requests", { status: 429 });
}
const { message } = await req.json();
if (typeof message !== "string" || message.length > 4000) {
return new Response("Bad request", { status: 400 });
}
const response = await client.responses.create({
model: process.env.OPENAI_MODEL!, // pin the model server-side
input: message,
});
return Response.json({ reply: response.output_text }); // return the answer, nothing else
}
単に隠すだけでなく、安全にするポイント:
- モデルはサーバー上に存在します。 ブラウザがモデルを選ぶと、攻撃者は最も高価なものを選びます。
- 入力は制限されています。 あなたのアカウントを通じて巨大なプロンプトが送られることはありません。
- 返されるのは回答のみです。 生のプロバイダーレスポンスやエラーオブジェクトを決して返さないでください。
- 公開プレフィックスはありません。
OPENAI_API_KEYはサーバーサイドに留まります。NEXT_PUBLIC_OPENAI_API_KEYはすべての訪問者に出荷されます。
ClaudeとGeminiの場合は、ANTHROPIC_API_KEYまたはGEMINI_API_KEYを使用してSDKを使います。GoogleのウェブサイトおよびIP制限は役立ちますが、curlを持つ誰でも任意のRefererヘッダーを送信できるため、これらは第2層として扱ってください。ブラウザ呼び出しの唯一の公正なケースは、各ユーザーが自分のキーを貼り付けるbring-your-own-keyツールです。
コーディングエージェントにApidog CLIでプロキシルートをテストさせる
キーを移動することは仕事の半分です。残りの半分は、エージェントが次に書く機能の後でもキーが移動されたままであることを証明することです。ルートを構築したエージェントに、Apidog CLIでテストさせましょう。一度セットアップします:
npm install -g apidog-cli@latest
apidog login --with-token <YOUR_TOKEN>
npx -y skills add https://apidog.com # pick your agent when prompted
次に、Claude Code、Codex、Cursor、または使用しているエージェントに1つのプロンプトを与えます:
Create Apidog test cases in project <id> for POST /api/chat on our staging
environment that assert:
1. a request without a session cookie returns 401,
2. a valid request returns 200 and the body does not contain "sk-", "sk-ant-",
"AIza" or "api_key",
3. the 21st request inside 60 seconds from the same user returns 429.
Run them and summarize any failures.
エージェントはスキーマを読み、ペイロードを検証し、ケースを書き、それらを実行します:
apidog test-case category --project <id>
apidog cli-schema get test-case-create
apidog cli-schema validate test-case-create --file case.json
apidog test-case create --project <id> --file case.json
apidog run --test-case <caseId> --project <id> -e <envId> -r cli,json
2番目のアサーションが最も重要です。プロバイダーのエラーオブジェクトやデバッグフィールドがクライアントにキーをエコーバックし始めた瞬間に失敗します。同じapidog run ... -r junitステップをパイプラインに配置して、すべてのデプロイで再チェックしてください。Apidog CLI CI/CDガイドがセットアップを示し、Claude CodeでのApidog CLIがエージェント側をカバーしています。ApidogのFreeプランは最大4ユーザーまでカバーするので、小規模チームは有料席なしでこれを実行できます。
同じ問題のリポジトリ側、つまりブラウザに出荷されるのではなくGitにコミットされたキーについては、シークレットスキャナーによるAPIキーリークチェックをご覧ください。エージェントが顧客データを扱うツールを呼び出す場合は、AIエージェントのデータリークテストが次の層をカバーします。
キーを露出させ続けるよくある間違い
- Base64エンコードやキーの分割。 ブラウザは送信するためにそれを再構築する必要があるため、DevToolsには実際の値が表示されます。
- localStorageやCookieに移動する。 それでもクライアント上にあります。
- 認証なしでプロキシする。 公開された
/api/chatは、あなたの請求で開かれたAIエンドポイントです。 - 「念のため」古いキーを保持する。 1日公開されていたキーはコピーされています。
- モバイルアプリを忘れる。 Googleのガイドが「Webアプリまたはモバイルアプリ」を一緒に挙げているのには理由があります。
よくある質問
OpenAI APIキーがブラウザで露出しているかどうかを知るにはどうすればよいですか? DevToolsのNetworkタブを開いた状態でAI機能をトリガーします。api.openai.comへのリクエストでAuthorization: Bearer sk-...ヘッダーがある場合、露出しています。また、読み込まれたスクリプトでsk-とdangerouslyAllowBrowserを検索してください。
APIキーをNEXT_PUBLIC_またはVITE_環境変数に入れるのは安全ですか? いいえ。これらのプレフィックスは、値をクライアントバンドルにコピーするために存在します。それらの下に保存されたプロバイダーキーは、すべての訪問者に出荷されます。プレフィックスのないサーバー専用変数を使用し、サーバールート内で読み取ってください。
Gemini APIキーを自分のウェブサイトに制限すると、ブラウザで安全になりますか? 役には立ちますが、ブラウザ外のスクリプトは任意のRefererヘッダーを送信できます。Google自身のガイドは、クライアントアプリにはバックエンドプロキシを推奨しています。
キーが露出しているのを見つけました。最初に何をすべきですか? 新しいキーを作成し、サーバーサイドにデプロイしてから、古いキーを無効化します。次に、呼び出しをユーザーごとのレート制限付きの認証済みルートの背後に移動し、プロバイダーの支出制限を設定します。
コーディングエージェントにこれを修正してテストしてもらえますか? はい。プロバイダー呼び出しをサーバールートに移動するよう依頼し、Apidog CLIを使用して、セッションなしで401、レスポンスにキーパターンがないこと、レート制限を超えると429をアサートするテストを作成して実行します。
