「ブラウザから叩くデモページ用に Gemini API キーを発行して、流出が怖いから HTTP リファラー制限をかけた。動作確認は通ったのに、本番ドメインに乗せた瞬間に 403 PERMISSION_DENIED で全リクエストが落ちる」— 個人開発で API キーを Web アプリに同梱しようとしたとき、最初に踏みやすい地雷です。
私自身、自社サイト(dolice.design)でちょっとした AI ガジェットを公開しようとしたときに同じ症状に行き当たり、リファラー文字列の書式と、SDK が裏で叩いている URL の関係を把握していなかったせいで丸半日溶かしました。個人開発で API キーを Web アプリに同梱する構成は何度か組んできましたが、API キー制限まわりは「壊れたときに焦らないこと」が一番大切だと感じています。ここでは原因の切り分けと、本番で安全に運用するための逃し方を整理します。
まず確認すべき症状の特徴
リファラー制限が原因の 403 は、以下のような症状の組み合わせで出ます。
- ローカル開発(
localhost/127.0.0.1)では成功するのに、本番ドメインからだけ落ちる、もしくはその逆 - ブラウザの DevTools で見ると
OriginとRefererが両方付いている GET/POST なのに 403 - レスポンス本文に
API keys with referer restrictions cannot be used with this APIやRequests from referer ... are blockedというメッセージが含まれる - サーバーサイドからの呼び出し(Node.js のバックエンド、Cloud Functions、Cron)だけが落ちる
逆に「キーそのものが無効」「課金が無効」のときは 400 INVALID_ARGUMENT や 403 SERVICE_DISABLED といった別メッセージになるため、本文を読めば切り分けはほぼ確定します。
なぜ「設定したはずなのに通らない」のか — 4 つの典型原因
1. スキームを書かずに example.com/* と入れている
Google Cloud Console の HTTP リファラー欄は、内部的に「URL を組み立ててから前方一致でマッチング」しています。example.com/* だけだとブラウザが送ってくる https://example.com/path の https:// 部分でマッチしません。正しくは https://example.com/* のように スキームを必ず付ける こと。https://*.example.com/* のようにサブドメインまで含めたい場合も、必ず先頭にスキームが必要です。
2. パス末尾の /* を省略している
https://example.com のように末尾を省略すると、https://example.com/ 1 個しかマッチしません。/api/gemini のような任意パスからも叩きたいなら https://example.com/* と書きます。私はここで「サブパスからのリクエストだけ落ちる」現象に遭遇して、ルーティングが壊れたのかと丸 1 時間勘違いしました。
3. SDK の裏で呼ばれる URL に対して付けていない
Vertex AI モードで動かしている場合、リクエスト先は aiplatform.googleapis.com です。素の Gemini API は generativelanguage.googleapis.com です。リファラー制限はクライアントが送る Referer ヘッダに対するチェックなので、エンドポイントの違いではなく どの呼び出しがブラウザから直接行われているか を確認するのが正解です。Edge Functions やプロキシ経由で叩いている場合、ブラウザからの Referer はプロキシで落ちるため意味がなくなります。
4. サーバーサイド呼び出しに付けてしまっている
ここが一番焦りやすい点です。サーバー(Node.js / Cloud Run / Cloud Functions)からのリクエストには Referer ヘッダが原則として付きません。リファラー制限を付けると サーバーからの呼び出しが常に 403 になります。サーバー用と Web 用でキーを分けるのが正解です。
切り分け 30 秒コマンド
curl で再現できるかどうかが最短ルートです。
# 1. リファラーなし(= サーバーサイド相当)
curl -s -o /dev/null -w "%{http_code}\n" \
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=YOUR_GEMINI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"contents":[{"parts":[{"text":"hi"}]}]}'
# 2. 許可ドメインのリファラーを偽装
curl -s -o /dev/null -w "%{http_code}\n" \
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=YOUR_GEMINI_API_KEY" \
-H "Content-Type: application/json" \
-H "Referer: https://example.com/app" \
-d '{"contents":[{"parts":[{"text":"hi"}]}]}'両方 403 なら、許可リストの書式が間違っている可能性が高い。1 が 403 で 2 が 200 なら、サーバーサイド呼び出しが落ちているだけなので、後述の「逃し方」に進みます。
別の壁:ブラウザから直接叩くと CORS で弾かれる
リファラー制限を正しく直したのに、ブラウザからの呼び出しだけ動かない。しかもレスポンス本文に 403 すら出ず、コンソールに blocked by CORS policy と表示される。これは API キーの問題ではありません。generativelanguage.googleapis.com は、ブラウザからのクロスオリジン直接呼び出しを想定しておらず、Access-Control-Allow-Origin ヘッダを返さないためです。
切り分けは DevTools の Network タブが早いです。
- 当該リクエストが
(failed)またはCORS errorになり、Response が空 → CORS で止まっています。サーバーには届いていません。 - ステータス 403 のボディが返っている → こちらはリファラー/キー制限の話なので、前の節に戻ります。
つまり「リファラーを直す」だけでは一生解決しません。preflight の OPTIONS がそもそも通らないので、fetch のオプションをいくらいじっても無駄です。私自身、mode: "cors" や headers を変えながら半日溶かしたことがありますが、ブラウザ側でできることは何もありませんでした。
解決策は次節の「ブラウザから直接叩かない」とまったく同じで、自前プロキシを一枚挟みます。違いは、プロキシ側で CORS ヘッダを明示的に返し、preflight の OPTIONS に応答してあげる点です。
// Cloudflare Workers: CORS 対応プロキシ
const ALLOW_ORIGIN = "https://example.com"; // ワイルドカードにしない
export default {
async fetch(req: Request, env: Env) {
// preflight への応答
if (req.method === "OPTIONS") {
return new Response(null, { status: 204, headers: corsHeaders() });
}
if (req.method !== "POST") {
return new Response("Method Not Allowed", { status: 405, headers: corsHeaders() });
}
const upstream = await fetch(
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=" + env.GEMINI_API_KEY_SERVER,
{ method: "POST", headers: { "content-type": "application/json" }, body: await req.text() }
);
return new Response(upstream.body, {
status: upstream.status,
headers: { ...corsHeaders(), "content-type": "application/json" },
});
},
};
function corsHeaders() {
return {
"Access-Control-Allow-Origin": ALLOW_ORIGIN,
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "content-type",
};
}ここで Access-Control-Allow-Origin を * にすると、確かに CORS は通りますが、今度はそのプロキシが誰からでも叩ける踏み台になります。キーは隠せても、他人にあなたのクォータを使われては本末転倒です。許可するオリジンは本番ドメインだけに絞り、プロキシ側でレート制限も入れておきます。
本番で安全に運用するための逃し方
A. キーを 2 系統に分ける(推奨)
最も素直な解決策です。Web 用キーは HTTP リファラー制限を厳格に付け、サーバー用キーは IP アドレス制限か API 制限のみを付けます。pricing.ts や .env で名前を分けておくと事故が起きにくいです。
// Web 用(埋め込み・難読化はするが流出前提のキー)
const WEB_GEMINI_API_KEY = process.env.NEXT_PUBLIC_GEMINI_API_KEY_WEB;
// サーバー用(流出絶対 NG・IP 制限あり)
const SERVER_GEMINI_API_KEY = process.env.GEMINI_API_KEY_SERVER;B. ブラウザから直接叩かない(より推奨)
長期的には、API キーをブラウザに同梱せず、自前の薄いプロキシを経由する形に寄せるのが安全です。Cloudflare Workers や Cloud Run で 10 行程度のフォワーダーを挟むだけで、キーをサーバー側に閉じ込められます。
// Cloudflare Workers: gemini-proxy
export default {
async fetch(req: Request, env: Env) {
if (req.method !== "POST") return new Response("Method Not Allowed", { status: 405 });
const body = await req.text();
const upstream = await fetch(
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=" + env.GEMINI_API_KEY_SERVER,
{ method: "POST", headers: { "content-type": "application/json" }, body }
);
return new Response(upstream.body, { status: upstream.status, headers: { "content-type": "application/json" } });
}
};この形にすると、HTTP リファラー制限ではなく 自前プロキシ側で Origin チェック + レート制限 を入れるほうが堅牢になります。個人開発でアプリやサイトを長く運用してきた経験から言うと、流出した API キーで一晩のうちに数万円分のクォータを消費されるリスクは現実にあり、キーをブラウザに直接見せる構成は最終的にどこかで卒業することになります。
C. App Check / Firebase AI Logic を併用する
Web/iOS/Android からの呼び出しに対しては、Firebase AI Logic(旧 Vertex AI for Firebase)の App Check 連携を組み合わせると、キーそのものを露出させずに「正規アプリからの呼び出しだけ通す」状態にできます。リファラー制限よりも強い保護になります。
設定後のチェックリスト
- 許可リストの全エントリにスキーム(
https://)が入っている - パス末尾に
/*が付いている - 開発用に
http://localhost:*http://127.0.0.1:*も追加してある(必要な場合) - サーバーサイド呼び出しは別キーになっている
- 反映までは数分かかるため、変更直後の 403 で焦らない(最大 5 分程度)
実装の参考になれば幸いです。最後までお読みいただきありがとうございました。