Pointing GOOGLE_GEMINI_BASE_URL at Your Own Gateway Does Not Change Who Holds the Key
I pointed the Antigravity CLI at a gateway of my own and recorded exactly which credentials travelled with it. Authorization is dropped across a cross-origin redirect, but the API key header survives every hop. Here is the measurement, and how I moved the key off my laptop.
I decided to put one layer between my machine and the model API. The motivation was modest: I wanted to count, in one place, how much each project was calling.
So I wrote my own URL into GOOGLE_GEMINI_BASE_URL and stood up a tiny server that printed whatever it received. It took a few minutes to work.
Then I read the headers that had arrived, and I stopped. The API key that lives on my laptop was sitting right there, one line among the rest.
In hindsight it could not have gone any other way. I changed the destination, and I never wrote an instruction to strip the credentials. Still, the fact that one line in a config file quietly changes where a key gets delivered felt lighter than I expected it to.
Antigravity CLI 1.1.13 added direct GEMINI_API_KEY authentication: set modelProvider: "gemini" in settings.json, export the variable, and the CLI runs without signing in. The same release note mentions that you can point GOOGLE_GEMINI_BASE_URL at a custom endpoint. It is the shortest path there is, so anyone aiming at a local engine or an internal gateway ends up here.
Which is exactly why it is worth looking, once, with your own eyes at what arrives on the other side. What follows was measured on my own machine, in the setup I use as an indie developer.
Stand up a receiver and record the headers as they arrive
Start with a recording, not a guess. This server does not forward anything upstream; it just collects headers.
# echo_srv.py - a receiver that records headers and nothing elseimport threading, http.server, socketserverLOGS = {}def make_handler(name, redirect_to=None): LOGS[name] = [] class H(http.server.BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" def do_POST(self): n = int(self.headers.get("content-length", 0)) self.rfile.read(n) # body discarded; headers are the subject LOGS[name].append({"path": self.path, **{k.lower(): v for k, v in self.headers.items()}}) # anything other than /final gets bounced once if redirect_to and not self.path.endswith("/final"): self.send_response(307) # 307 preserves method and body self.send_header("Location", redirect_to) self.send_header("Content-Length", "0") self.end_headers() return body = b'{"ok":true}' self.send_response(200) self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def log_message(self, *a): # keep the default access log quiet pass return Hdef serve(port, handler): socketserver.TCPServer.allow_reuse_address = True srv = socketserver.TCPServer(("127.0.0.1", port), handler) threading.Thread(target=srv.serve_forever, daemon=True).start() return srv
I picked 307 rather than 302 because some implementations rewrite a POST into a GET on a 302, which adds a variable I am not trying to measure. Only the thing under test should move.
With the destination swapped and nothing else, the credentials arrive intact
The plain path first, with no redirect in the way. Whatever the client sends shows up on the receiver.
import requestsHDRS = { "Authorization": "Bearer ADC_TOKEN", # dummy values; no real secrets here "x-goog-api-key": "KEY", "x-goog-user-project": "my-project", "Content-Type": "application/json",}serve(8914, make_handler("plain"))requests.post("http://127.0.0.1:8914/a", headers=HDRS, json={}, timeout=5)d = LOGS["plain"][-1]print(sorted(k for k in d if k in ("authorization", "x-goog-api-key")))# output: ['authorization', 'x-goog-api-key']
Both arrive. A base_url setting chooses a route; it never touches how credentials are handled. Obvious once stated, but seeing it makes the next difference much easier to read.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦You can record exactly which credentials leave your machine before you point a client at a gateway or a local engine, instead of finding out later
✦You can design your forwarded-header list around a real asymmetry: Authorization is stripped across origins while the API key header is not
✦You can move the upstream key off your laptop and onto a gateway in an order that never leaves you guessing whether the block actually works
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Add one redirect and the headers split into two groups
Write your own gateway and redirects show up sooner or later. A load balancer's preference, an upgrade from http to https, a move from an old host to a new one. Each of those is a single 307.
So I built three routes on the same machine and compared them: a bounce to the same host and port, a bounce to the same host on a different port, and no bounce at all.
serve(8911, make_handler("A", "http://127.0.0.1:8911/final")) # same host, same portserve(8912, make_handler("B", "http://127.0.0.1:8913/final")) # same host, other portserve(8913, make_handler("C")) # the target aboveserve(8914, make_handler("D")) # no redirectdef last(name): d = LOGS[name][-1] return d["path"], sorted(k for k in d if k in ("authorization", "x-goog-api-key"))requests.post("http://127.0.0.1:8911/a", headers=HDRS, json={}, timeout=5)print("same host, same port:", last("A"))requests.post("http://127.0.0.1:8912/a", headers=HDRS, json={}, timeout=5)print("same host, other port:", last("C"))requests.post("http://127.0.0.1:8914/a", headers=HDRS, json={}, timeout=5)print("no redirect:", last("D"))
Here is what printed on my machine.
same host, same port: ('/final', ['authorization', 'x-goog-api-key'])same host, other port: ('/final', ['x-goog-api-key'])no redirect: ('/a', ['authorization', 'x-goog-api-key'])
I ran the same three routes through httpx with follow_redirects=True and got results that matched line for line. That reads less like one library's quirk and more like both of them implementing the same rule.
Route
Authorization
x-goog-api-key
No redirect (destination swap only)
arrives
arrives
307 to the same host and port
arrives
arrives
307 to the same host, different port
dropped
arrives
The host stayed 127.0.0.1 throughout. Only the port number changed, and that alone was enough to count as a different origin and have Authorization removed.
The header that got dropped is the protected one; the header that survived is the one doing the work
This is where my expectation turned out to be backwards.
What I had been worried about was a credential following me across a redirect. The credential that actually got removed was Authorization. The clients carry a protection for it: cross an origin boundary and it comes off.
x-goog-api-key sailed straight through. So did x-goog-user-project. From the client's point of view those are ordinary custom headers, and nothing in the redirect rules recognises them by name.
And on the GEMINI_API_KEY path, the header that actually authenticates the request is not the one that was removed. It is the one that survived. The protection covers the header with a famous name, not the header that is functioning as the key.
You do not arrive at that asymmetry by reasoning about it. Documentation describes the correct use of its own client; it does not describe what happens when you detour through someone else's server.
One more thing worth saying plainly. Every measurement above ran over http://. I was sending a key-bearing header in the clear, and the client said nothing about it. Aimed at a local engine it stays inside 127.0.0.1 and the practical risk is small, but the moment an internal hostname goes into that variable, it is a different conversation.
Take the key off the laptop and give it to the gateway
Once the cause is clear the fix is plain. Make forwarding an allowlist, and let the gateway attach the upstream key itself.
FORWARD_ALLOW = {"content-type", "accept"} # list what passes; everything else does notdef gateway(strict): class H(http.server.BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" def do_POST(self): n = int(self.headers.get("content-length", 0)) body = self.rfile.read(n) inbound = {k.lower(): v for k, v in self.headers.items()} if strict: out = {k: v for k, v in inbound.items() if k in FORWARD_ALLOW} out["x-goog-api-key"] = "UPSTREAM_KEY_HELD_BY_GATEWAY" # only this layer knows it else: # pass-through: drop hop-by-hop headers, forward the rest untouched out = {k: v for k, v in inbound.items() if k not in ("host", "content-length", "connection")} requests.post("http://127.0.0.1:8923/v1", headers=out, data=body, timeout=5) r = b'{"ok":true}' self.send_response(200) self.send_header("Content-Length", str(len(r))) self.end_headers() self.wfile.write(r) def log_message(self, *a): pass return H
Sending the same client request through both, here is what reached the upstream.
The pass-through version hands the upstream my laptop's key, my project name, and my client version. The strict version hands it a key that belongs to the gateway and nothing else.
The line that makes the difference is FORWARD_ALLOW. Write it as a deny list and you will leak quietly on the day a new header appears. Write it as an allow list and the default is to drop.
If you add a cache, identify the credentialed requests first
Once a gateway exists, a cache is the next thing you want. Not paying twice for the same question is a natural wish.
This is also a place I have tripped over while running article sites on Cloudflare Workers. I once let a member-only response into the cache layer, and I rebuilt it so that requests carrying identity are recognised and passed straight through. That check now sits in the first branch at the entrance.
Putting a layer in front of a model API has the same shape. If the key or the project name differs per request, those are part of the cache key. Strip headers before you compute the key and you have just made room for someone else's answer.
Decision point
What to look at
What breaks if you get it wrong
Forwarded headers
Is it a list of what passes?
New headers reach upstream silently
Cache key
Does it include the identity headers?
Another caller's response comes back
Log retention
Are you storing whole header maps?
Keys accumulate in your logs
The third one is a hole this very investigation nearly fell into. I am the one who wrote a receiver that collects every header, and carrying that code into a permanent gateway would pile up key-bearing logs every day. The tool you investigate with and the tool you leave running are two different programs.
The hole I nearly left in the permanent version
Moving from a throwaway receiver to a permanent gateway, I wrote a version that let the block fall out from under itself. It returned the upstream's 307 straight back to the client.
def redirecting_gateway(): # returns the upstream 307 to the client instead of following it class H(http.server.BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" def do_POST(self): n = int(self.headers.get("content-length", 0)) self.rfile.read(n) self.send_response(307) self.send_header("Location", "http://127.0.0.1:8933/v1") # straight to upstream self.send_header("Content-Length", "0") self.end_headers() def log_message(self, *a): pass return H
The gateway itself was still strict. Here is what the upstream received anyway.
when the gateway answers with a 307, the upstream receives: authorization -> (none) x-goog-api-key -> DEV_LAPTOP_KEY x-goog-user-project -> my-project
The laptop's key landed upstream. The client follows the redirect on its own and issues a second request directly, and the gateway plays no part in that second request. FORWARD_ALLOW is never evaluated.
Only authorization is missing, because the cross-origin protection from earlier did its job. Ironically, that protection made it look as though something was being filtered, which is why the gap took me an extra day to notice — I found it while re-reading the upstream logs.
There are two fixes: follow the redirect inside the gateway and return only the response, or treat any 3xx as an error and stop. In this situation I would recommend stopping. Following redirects means taking on the job of checking, every single time, that the new destination is one you allow. Stop instead, and the set of destinations stays inside your config file.
For anything permanent, I would also count how often a 3xx shows up. It is probably the first place you will notice that a route quietly grew a hop on the day the upstream changed shape.
Switch over in an order that does not break what already works
Replace everything at once and a failure tells you nothing about where it came from. The order I used was this.
Stand up only the receiver, point GOOGLE_GEMINI_BASE_URL at it, and print the header names once. Not the values
Read that list and decide what may go upstream. Anything you hesitate over goes in the drop pile
Give the gateway an upstream key and run it in strict mode. Forwarding starts here, not before
Replace your local GEMINI_API_KEY with a gateway-only value, distinct from the upstream key
Delete the upstream key from the machine. Confirm calls still succeed, and call the switch done
Steps 4 and 5 are separate on purpose. Leave the same key in both places and you cannot tell whether the block is working or whether the call simply happens to succeed with an identical key. Only a different value turns this into a test of the route.
The setting picks the destination; I am the one handing over the key. One environment variable changes the route with almost no effort, and that same lack of effort changes where a credential gets delivered. The weight of the setting and the weight of the key are not the same.
Stand up the receiver from this article at whatever GOOGLE_GEMINI_BASE_URL currently points to, just once. No forwarding required. Listing the header names that arrive is enough to show you what your machine is handing out.
I set mine up intending to count requests, and saw something I had not expected instead. Put the measuring tool down first, and the design decisions tend to follow on their own.
I am still working this out as I go, and if you find a header I should have dropped, I would be glad to learn about it.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.