"I generated a Gemini API key for a small browser-side demo, locked it down with HTTP referrer restrictions because I was nervous about leaks, watched it work in local testing — and the moment it went live every request started returning 403 PERMISSION_DENIED." This is one of the first traps you fall into when embedding an API key in a public Web app.
I ran into the exact same wall on my own portfolio site (dolice.design) while shipping a small AI gadget. I burned half a day before I understood how the referrer string format relates to the URL the SDK is actually hitting. As an indie developer who has embedded API keys into Web apps more than a few times, I have come to believe that the single most important thing about API key restrictions is "do not panic when they break." Here is how to diagnose the issue and route around it safely in production.
Symptoms that point at referrer restrictions
A 403 caused by referrer restrictions usually appears with this specific combination of signals:
- Local development (
localhost/127.0.0.1) succeeds, but the production domain fails — or vice versa - DevTools shows both
OriginandRefererare present on the request, yet you still get 403 - The response body contains
API keys with referer restrictions cannot be used with this APIorRequests from referer ... are blocked - Server-side calls (a Node.js backend, Cloud Functions, a cron job) are the only ones failing
If the key itself is invalid or billing is off, you get a different 400 INVALID_ARGUMENT or 403 SERVICE_DISABLED instead. Reading the response body resolves the ambiguity almost immediately.
Why it does not match — four typical mistakes
1. Omitting the scheme: writing example.com/*
The HTTP referrer field in Google Cloud Console builds a URL internally and matches it as a prefix. example.com/* alone fails to match https://example.com/path because the https:// prefix never lines up. Always include the scheme — https://example.com/*, or https://*.example.com/* for subdomains.
2. Dropping the trailing /*
https://example.com only matches https://example.com/ itself. If you call from any subpath like /api/gemini, write https://example.com/*. I once spent an hour convinced my routing was broken before realizing requests from subpaths were the only ones blocked.
3. Attaching restrictions to the wrong endpoint
In Vertex AI mode the SDK calls aiplatform.googleapis.com. With the bare Gemini API it calls generativelanguage.googleapis.com. Referrer restrictions check the Referer header the client sends, so what matters is which call is actually being made from the browser. If you front the API with an Edge Function or proxy, the browser's Referer never reaches Google in the first place, and the restriction becomes meaningless.
4. Putting referrer restrictions on a server-side key
This is the easiest one to panic over. Requests from a server (Node.js, Cloud Run, Cloud Functions) generally carry no Referer header at all. The moment you attach a referrer restriction, every server-side call returns 403. The fix is to keep separate keys for Web and server.
A 30-second triage with curl
The fastest way to confirm the diagnosis is to reproduce it with curl.
# 1. No referrer (= server-side scenario)
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. Pretend to be one of your allowed domains
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"}]}]}'If both return 403, the allowed list almost certainly has a formatting issue. If 1 returns 403 but 2 returns 200, the only problem is your server-side calls — jump to the "how to route around it" section.
A different wall: calling from the browser gets blocked by CORS
You fixed the referrer restriction correctly, yet calls from the browser still fail — and this time there is no 403 body at all, just blocked by CORS policy in the console. This is not an API key problem. generativelanguage.googleapis.com is not designed for cross-origin calls straight from a browser, so it never returns an Access-Control-Allow-Origin header.
The DevTools Network tab settles it quickly:
- The request shows
(failed)orCORS errorwith an empty Response → it stopped at CORS and never reached Google's servers. - A 403 body did come back → that is the referrer/key story; go back to the previous sections.
In other words, no amount of "fixing the referrer" will help here. The preflight OPTIONS request never passes, so tweaking your fetch options does nothing. I once spent half a day toggling mode: "cors" and headers before accepting that there is nothing the browser side can do.
The fix is exactly the same thin proxy described in the next section. The only addition is that the proxy must return CORS headers explicitly and answer the preflight OPTIONS.
// Cloudflare Workers: CORS-aware proxy
const ALLOW_ORIGIN = "https://example.com"; // do not use a wildcard
export default {
async fetch(req: Request, env: Env) {
// Answer the 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",
};
}Setting Access-Control-Allow-Origin to * does make CORS pass, but it also turns your proxy into an open relay anyone can hit. Hiding the key is pointless if a stranger can spend your quota. Restrict the allowed origin to your production domain and add rate limiting on the proxy itself.
How to run this safely in production
A. Split the key into two (recommended)
The most straightforward fix. The Web key gets strict HTTP referrer restrictions; the server key gets IP restrictions or API-only restrictions. I like keeping them under distinctive names in pricing.ts or .env so the wrong one cannot be picked up by mistake.
// Web (embedded, obfuscated but assumed to be discoverable)
const WEB_GEMINI_API_KEY = process.env.NEXT_PUBLIC_GEMINI_API_KEY_WEB;
// Server (must never leak; carries IP restriction)
const SERVER_GEMINI_API_KEY = process.env.GEMINI_API_KEY_SERVER;B. Do not call from the browser at all (more recommended)
In the long run, the safer pattern is to stop shipping the API key into the browser at all and route through a thin proxy of your own. A ten-line forwarder on Cloudflare Workers or Cloud Run keeps the key entirely on the server side.
// 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" } });
}
};Once you have a proxy, Origin checks plus rate limiting on your own edge become a far stronger defense than referrer restrictions could ever be. Years of running my own apps and sites in production have taught me that the risk of a leaked key burning tens of thousands of yen of quota overnight is not theoretical, and every project that started by embedding a key in the browser eventually had to graduate from that pattern.
C. Combine with App Check / Firebase AI Logic
For Web/iOS/Android clients, pairing Firebase AI Logic (the rebrand of Vertex AI for Firebase) with App Check lets you authorize "only requests coming from your legitimate app" without exposing the key at all. It is materially stronger than referrer restrictions.
Post-config checklist
- Every entry in the allowed list begins with a scheme (
https://) - Every entry ends with
/* http://localhost:*andhttp://127.0.0.1:*are included if needed for local development- Server-side calls use a separate key without referrer restrictions
- Console changes can take a few minutes to propagate — do not panic over a 403 you see in the first five minutes
Hope this saves you the half day it cost me. Thanks for reading.