We shipped an API key in our web bundle. It got scraped.
A Gemini API key, hardcoded in the ShotCanvas web app, rode the JavaScript bundle into production. Someone's scraper found it, ran traffic we never sent, spiked the bill, and got the account suspended. Nothing about the attack was sophisticated — which is exactly the warning. Here's the post-mortem, and the three tripwires that make a repeat impossible.
What actually happened
The key got into the code the way these things always do: an AI feature under construction, a key pasted in as a string constant so the demo works, a mental note to move it somewhere proper later. Later never came. The feature shipped, the bundler carried the string into the production JavaScript verbatim, and from that moment the key was published — sitting on a public URL, inside a file anyone could read.
Someone did. The first sign wasn't a breach notification or a clever exploit — it was usage: request volume we never generated, spend climbing on an API we were barely using ourselves, and eventually a suspended account. Whoever found the key wasn't targeting us. They were running the same automated sweep they run against everyone, harvesting keys from shipped bundles and public repos and burning them until they die.
Rotating the key ended it. That's the first lesson, and it's the one to act on before reading further: rotation is the incident response. The architecture fix can wait an hour. The key can't.
Your bundle is public. All of it.
A web bundle feels like the inside of your app, but it's a file your server hands to anyone
who asks — no login, no browser required, curl will do. Minification renames
variables and mangles whitespace; it does not touch string literals. Your key survives the
entire build pipeline character-for-character, and it's trivially findable, because provider
keys are built to be recognizable: Google API keys start with AIza, OpenAI keys
with sk-. A one-line regex over your bundle finds them, and one-line regexes
over other people's bundles is an entire scavenging industry. You are not too small to be
found. The scanning is indiscriminate — being found is the default.
The tempting half-fix is to stop hardcoding the key and fetch it at runtime instead — from a config endpoint, remote config, a Firestore document. This changes nothing. The key still arrives in the browser, where it's one Network-tab click away. You've moved the leak from the bundle to a request; the client still ends up holding the secret. Mobile isn't shelter either: strings in an APK are one unzip away from anyone who downloads your app.
Key restrictions are a seatbelt, not a wall
Providers let you restrict a key — to certain referrers, certain IPs, certain APIs. Do it; it shrinks the blast radius. But understand what it doesn't do. A referrer restriction only binds clients that tell the truth about their referrer, and nothing outside a browser has to. And a key locked down to exactly the one expensive API your app uses is still fully usable for that API — which is precisely what an abuser wants it for. Restrictions cap the damage. They do not make a public key private.
The only real fix: the key never reaches the client
The pattern that actually works is boring: a proxy you own. The browser calls your endpoint
— /api/generate — authenticated as your user. Your server injects the provider
key from an environment variable, forwards the request, returns the response. The key lives
in exactly one place, on hardware you control, and the client never holds anything more
sensitive than its own session token.
The proxy pays rent beyond secrecy, too. You get per-user rate limits, so an abuser hits your throttle instead of your invoice. You get logging, so a traffic spike has a name attached. You get to swap models or providers server-side without shipping a client update. All of ShotCanvas's AI calls go through this pattern now, and the endpoint is a few dozen lines — the cost of doing it right rounds to an afternoon.
One genuine exception, before your secret scanner ruins a week: the Firebase web config "API key" is designed to be public. It identifies your project rather than authorizing spend; security comes from Security Rules and App Check, not secrecy. Don't proxy it, and teach your tooling to allow it — a scanner that cries wolf gets bypassed, and a bypassed scanner is how the next real key gets out.
Three tripwires, because "never again" isn't a control
After the rotation we said the thing every team says: never again. But a rule someone has to remember is a rule that eventually fails, so it's enforced by machines at three points instead:
At commit time. A git hook, installed globally so every repo on the machine gets it,
scans each commit and push for key patterns and blocks the ones that match. False positives
get an explicit, per-line allowlist entry — never a --no-verify, because the
habit of bypassing the scanner is worse than any single false positive.
At build time. A script scans the emitted bundle — not the source — for key patterns, and fails the build on a hit. This is the layer that catches what commit scanning can't: a key that arrives via a dependency, a config file, or any path that ends with a secret inlined into shipped JavaScript. If it's in the artifact, the deploy never happens.
At spend time. Billing alerts on the provider account, thresholds set low enough to notice fast, and a hard cap where the provider supports one. If the first two layers ever both fail, the difference between a bad morning and a bad month is how quickly the bill tells you.
Cheap, as security lessons go
The incident cost real money, a suspended account, and some pride — which, as security education goes, is a bargain. What it bought is an architecture where there's nothing left to scrape: every AI feature in ShotCanvas — the headline writer, the auto-designer, the metadata generator — talks to a proxy that holds the keys, behind a scanner that guards every commit and every build. If you'd rather see what those features make than how they're plumbed, the Studio is a click away.