Tracer is a site I built that surfaces ATF firearms-license violation data, so citizens can see which dealers are breaking the law. It had an AI feature that drafted outreach scripts, and the Gemini key for it sat server-side in a Google AI Studio deployment on Cloud Run. That design was correct. The key never reached a browser.
The bill was not. Thirty-eight million input tokens on gemini-4-argon, and a single line item of $153.79 for the month. My app asks for exactly one model, gemini-2.5-flash, and it is named in one place in its source. gemini 4 argon appears nowhere in the deployed app. Total Gemini charges on November 4 alone came to about $51 across five SKUs, and the project’s spend ran into the several-hundred-dollar range over roughly six weeks.
Two things settled it. The SKU named a model my code never asks for, and I had not touched AI Studio while the charges accrued. There was no version of my own use that produced either one.
The cause was structural rather than a leaked key. AI Studio’s deploy ships its own harness around your code. It holds the key server-side and rewrites every model call to /api-proxy on the deployment. That endpoint was unauthenticated, and it answered Access-Control-Allow-Origin: *, which means any website on the internet could call it from a visitor’s browser. It listed all fifty models, the frontier ones included. The only limit was 100 requests per 15 minutes per IP, and rotating an IP removes a limit like that.
What I did, in order:
- Deleted every API key in the project. The site has to stay publicly callable to serve pages, so the credential was the thing to kill, not the service.
- Built the hardened version anyway: model allowlist, POST-only with an action allowlist, no model listing, CORS pinned to my origin, strict rate limiting backed by a Durable Object instead of Cloudflare’s approximate one, and the key injected server-side so an incoming one from the client is discarded.
- Then retired the AI feature and deleted the Cloud Run service. The hardened proxy worked, but a public endpoint that reaches an LLM gets found again, and the feature was never worth the residual risk it carried.
- Replaced the whole deployment with a Cloudflare Worker that serves the built site from its own static files. There is no origin behind it left to attack.
- Removed the Vite
definethat compiled the key into the client bundle. It was never the leak here, because nothing referenced it, but it was armed. One line of new code would have shipped the key to every visitor. - Set a spend cap on the project as the backstop, because it is the only control that hard-stops money.
Rate limits bound the rate, never the total. A 30-per-minute cap is still 43,000 requests a day. Only two things bound a total, a spend cap or an auth gate on the expensive path, and only one of those is a guarantee. Open to the public and no spend risk cannot both be true.
The retired path is one environment flag and one secret away from coming back. Have you checked what your public endpoints can spend?