AIツールにGeminiキーをブラウザに置いてはいけない理由 — サーバーサイドプロキシ完全ガイド

5月 4, 2026

GeminiOmniの初回バージョンが、APIキーを公開から17分でスクレイピングされた話を読んだ数名の方から、プロキシアーキテクチャの完全版を教えてほしいとリクエストがありました。この記事がそれです。

有料のAI API(Gemini、OpenAI、Anthropic、Replicate、従量課金キーを使うものすべて)を呼び出すものを構築しているなら、以下のパターンが、火曜の朝に突然の課金アラートで目覚めるのを防ぐ盾になります。複雑ではありません。しかし、絶対に必要なものです。

アーキテクチャ、正しく実装すべき4つの具体的なポイント、そして本番環境で1ヶ月動かして遭遇したエッジケースについて説明します。

基本パターンの図解

どのAIプロバイダーを使う場合でも、構造は同じです:

┌─────────────────┐       ┌──────────────────────┐       ┌──────────────┐
│                 │       │                      │       │              │
│  ブラウザ       │       │  あなたのAPIルート   │       │  プロバイダー│
│  (信頼しない)   │  ───→ │  (サーバー、鍵保持) │  ───→ │  (Gemini /   │
│                 │       │                      │       │   OpenAI...) │
│  - プロンプト   │       │  - 認証チェック      │       │              │
│  - アップロード │       │  - レート制限        │       └──────────────┘
│                 │       │  - クォータ確認      │
│                 │       │  - APIキー付与       │
│                 │       │  - プロバイダー呼出  │
│                 │       │  - 結果を返却        │
│                 │ ←───  │                      │
└─────────────────┘       └──────────────────────┘

重要なのは、ブラウザにはプロンプトが入って結果が出てくることだけが見える点です。どのモデルが呼ばれたか、APIキーは何か、上流のプロバイダーの構成、呼び出しのコストはいくらか、といった情報は一切わかりません。これがブラウザが持つべき正しい情報量です。

「正しい」状態の具体的な定義

このアーキテクチャが実際に保護として機能するには、4つの条件が満たされている必要があります。一つでも欠けると、保護は崩壊します。

1. APIキーはサーバー上にのみ存在する

Next.jsの用語で言えば、環境変数は NEXT_PUBLIC_ で始まってはいけません。実行時には、ルートハンドラ(app/api/.../route.ts)、サーバーコンポーネント、またはサーバーアクション内でのみキーにアクセスします。

// app/api/ai/generate/route.ts — サーバー専用
const GEMINI_KEY = process.env.GEMINI_API_KEY; // ✅ サーバー専用
if (!GEMINI_KEY) throw new Error('GEMINI_API_KEY not set');

GEMINI_API_KEY が欠けている場合にアプリをクラッシュさせる起動時チェックは、非常に価値があります。最悪の障害モードは、キーがないままアプリが静かに動作し、予備費を空にするようなデフォルト値にフォールバックすることです。

2. ブラウザがプロバイダーの生のレスポンスを決して見ない

プロバイダーの呼び出しが戻ってきたら、何を返すかを選別します。ブラウザが知る必要はありません

  • どのモデルが正確に呼ばれたか(Veo 3.1 Fast か Veo 3.1 Standard か)
  • プロバイダーのリクエストID(サポートには有用だが、クライアントには無用)
  • プロバイダーからのレート制限ヘッダー
  • 呼び出しのトークン数やコスト

これが重要な理由:情報開示は雪だるま式に悪化します。ブラウザが "model: gemini-2.5-flash, tokens: 4096" を見ることができれば、スクレイパーはあなたのフロントエンドからプロバイダーの価格ページへとマッピングし、ユニットエコノミクスを逆算できます。壊滅的ではありませんが、良い状態ではありません。削除しましょう。

// UIが必要とするものだけを返す
return Response.json({
  imageUrl: result.generated_images[0].url,
  watermarked: !user.isPro,
  // 返さないもの:モデル名、使用トークン数、生のプロバイダーレスポンス
});

3. 認証、レート制限、クォータ確認はプロバイダー呼び出しのに行う

プロキシは単なる通過点ではありません。ルールを強制する場所です:

// APIルート内で、以下の順序で:
1. リクエストを認証する(セッションクッキー、JWTなど)。
   匿名リクエストは無料機能のために許可するが、IPで追跡する。

2. ユーザーID(匿名の場合はIP)でレート制限をかける。
   GeminiOmni:匿名は10 req/min、Proは60 req/min、Teamは200 req/min。

3. 特定の操作に対するユーザーのクォータを確認する。
   無料ユーザーは月に5回の動画生成 — 高価な呼び出しの****
   データベースを確認し、後では確認しない。

4. ここで初めて — プロバイダーを呼び出す。

5. 成功時に使用量カウンターを増やす。

順序が重要です。最初にプロバイダーを呼び出してからクォータをチェックした場合、ユーザーに権利があるかどうかに関わらず、推論のコストを既に支払ってしまっています。すべてのクォータチェックは事前に行います。

4. プロキシは独自の可観測性を持つ

プロキシからのすべてのプロバイダー呼び出しを記録します:タイムスタンプ、ユーザーID、ルート、モデル、入力サイズ、出力サイズ、レイテンシ、成功/失敗、おおよそのドルコスト。これはあなたの課金レシートであり、デバッグの入り口です。

await logUsage({
  userId,
  route: '/api/ai/generate',
  model: 'gemini-2.5-flash-image',
  inputBytes: prompt.length + imageBytes,
  outputBytes: result.length,
  latencyMs: Date.now() - start,
  costUsd: estimateCost(model, inputBytes, outputBytes),
  status: 'success',
});

火曜の朝に、一晩で支出が倍増したという課金アラートが届いたとき、このテーブルに対してSQLを実行して、どのユーザー、どのルート、どのモデルかを特定します。これがなければ、推測するしかありません。

本番稼働で遭遇したエッジケース

1ヶ月間プロキシを本番で動かして、いくつか非自明な問題が見つかりました。

ストリーミング応答にはストリーミングプロキシが必要。 Geminiの streamGenerateContent エンドポイントは、部分的な生成結果のストリームを返します。プロキシ内で単純に response.json() を呼び出すと、ストリーム全体をバッファリングしてしまい、レイテンシの利点が失われます。解決策は、上流のストリームを ReadableStream としてプロキシ経由でパイプすることです。これにより、APIルートは Response.json(...) ではなく new Response(stream) を返すようになります。行う価値はあります — 動画生成の知覚レイテンシが「90秒待つ」から「すぐに何かが起こっているのを見る」に変わります。

大きなファイルのアップロードは、ストレージへの直接アップロードが必要。 ユーザーが画像から動画を生成するために50MBの動画をアップロードする場合、その50MBをNext.jsのAPIルート経由で転送させたくはありません。パターンはこうです:クライアントは署名付きURLを使ってCloud Storage / R2に直接アップロードし、プロキシにはそのURLだけを送信します。プロキシはそのURLをGeminiの"media URI"パラメータに渡し、バイトデータはサーバーを経由しません。これにより、アップロードが多いパスでの送信課金を約95%削減できます。

上流からのエラーは翻訳が必要。 Geminiは "PROMPT_BLOCKED""QUOTA_EXCEEDED""INVALID_ARGUMENT" のようなエラーを返します。ブラウザがこれらの文字列を見る必要はありません — 混乱を招き、プロバイダーの詳細を漏洩させます。それぞれをユーザー向けのメッセージに変換します:「プロンプトが安全フィルターによってブロックされました」「今月の無料生成回数を使い切りました」「その画像は破損しているようです」。3行のルックアップテーブルで十分です。

コスト見積もりはトークン単位ではなく、呼び出し単位。 各呼び出しのドルコストをトークン数で見積もろうとしました。その後、Veoの課金は出力動画の秒数、Imagenは画像数、Nano Banana 2は解像度に基づく4段階の価格であることに気付きました。プロキシのコスト関数は掛け算ではなく、switch文です。これを回避するきれいな方法はありません — すべてのプロバイダーの課金体系は異なり、それぞれをコードにエンコードする必要があります。

GeminiOmniコードベースでの実装

/tools/text-to-video/tools/image-to-video/tools/nano-banana-edit/tools/pdf-chat、そして /chat のライブチャットにおけるすべてのモデル呼び出しは、app/api/ai/generate/route.tsapp/api/ai/query/route.ts を経由します。2つのエンドポイントです。合わせて約400行のコード。上記の4つのチェックはすべて、プロバイダー呼び出しがサーバーを出る前に強制されます。

私はモデル名をユーザー向けUIには表示しましたが、APIレスポンスには含めませんでした。生成された各クリップの下のバッジには「Veo 3.1 Fast」と表示されます — これはレンダリング時にサーバーサイドで参照されたもので、APIレスポンスから来たものではありません。これは、APIが返す文字列はすべて、スクリーンショット一つで標的になり得るという現実を考えれば、馬鹿げたことではありません。

正直なまとめ

プロキシパターンは興奮するものではありません。そこに巧妙なアルゴリズムはありません。これは、支払っていない人々に資金を奪われることなく、有料のAI APIを公開インターネット上で実行するために必要な、最低限のものに過ぎません。

しかし、それが最低限のものです。私が知っている、これなしでローンチしたインディー開発者は皆、同じように教訓を学びました:不意の時間に届く課金アラート、慌ててキーを無効にし、データフローを書き直す長い夜。初日からやっておく方が安上がりです。

次に読む

— Lena

Lena Hoffmann

Lena Hoffmann

AIツールにGeminiキーをブラウザに置いてはいけない理由 — サーバーサイドプロキシ完全ガイド | ブログ