How I Accidentally Leaked My API Key — And the Security Whack-a-Mole Saga That Followed
I leaked my Gemini key in Vite, which sucked. But the real adventure was the subsequent descent into the absolute madness of API key restrictions, proxy referrers, and the ultimate Boss Fight: Firebase Auth popup lockouts.
How I Accidentally Leaked My API Key — And the Security Whack-a-Mole Saga That Followed
Yesterday I got that notification you never want to see.
"Your API key has been compromised. Usage has spiked."
My stomach dropped. I scrambled to the cloud console, revoked the key, and sat there staring at the ceiling wondering: how?
I keep my .env files gitignored. I don't hardcode secrets. I'm not a beginner. And yet, there it was — my key, floating out in the wild, being used by who-knows-who to generate who-knows-what.
Act I: The Sherlock Holmes Phase
I tore through my repo. Checked every commit. Searched for the key string. Nothing. It was never committed. So how did it leak?
Then I looked at the env var name:
VITE_GEMINI_GOOGLE_AI_API_KEY
VITE.
If you know, you know. If you don't, here's the kicker: Vite automatically inlines every env var prefixed with VITE_ into your client-side JavaScript bundle.
At build time, vite build runs through your code, finds import.meta.env.VITE_GEMINI_GOOGLE_AI_API_KEY, and replaces it with the literal string value — right there in the bundled JS file that gets served to every single browser that visits your site. Anyone who opened DevTools, went to the Sources tab, and searched for "AIza" would have found it in about fifteen seconds.
The old architecture was a ticking time bomb:
Browser ──► AIModel.jsx (client-side)
│
└── import.meta.env.VITE_GEMINI_GOOGLE_AI_API_KEY
│
Vite inlines this at build time
Key ends up in the JS bundle!
Act II: The Great AI Engine Migration & Prepaid Safety
Instead of just replacing the key and waiting to get burned again, I decided to overhaul the entire application architecture. I wanted to build a fully provider-agnostic AI completions engine—capable of running alternative endpoints or vendors without changing a single line of frontend code.
To significantly reduce my financial risk and avoid the dreaded post-paid cloud billing shock (looking at you, Google and AWS), I switched my AI provider to DeepSeek and topped up a minimal balance on a prepaid account.
If an attacker spams my endpoints now, they don't rack up thousands of dollars on my credit card. They hit the prepaid ceiling, the API pauses, and I sleep a lot easier at night.
But setting up server routes was just the opening act. The real security boss fights were about to begin.
Act III: The Google Cloud Key Restrictions Whack-a-Mole
To secure my public Google and Firebase keys, I went into the Google Cloud Console and added HTTP Referrer Restrictions to lock them to localhost and my production domain (your-app.vercel.app).
Instantly, the app broke. Welcome to Whack-a-Mole.
Gotcha #1: The Serverless Referrer Lockout
My frontend calls a secure backend API proxy to query Google's Places API. Because the key was restricted strictly to authorized HTTP referrers, and serverless backend functions run in isolation without a default web browser context, the cloud provider initially blocked my server-side proxy requests.
The Fix: I configured the serverless backend proxy to securely pass the designated validation headers required by the API provider. This allowed the server-to-server calls to authenticate successfully while keeping the API key completely hidden from the user's browser.
Gotcha #2: The Wildcard Nightmare
Even with referrers simulated, navigating to https://your-app.vercel.app/create-trip threw a RefererNotAllowedMapError.
Why? Because Google's referrer system is highly literal. If you authorize https://your-app.vercel.app, it only authorizes the homepage. If you visit /create-trip, it fails!
- The Fix: The holy wildcard trailing asterisk (
/*). - You must restrict keys using
https://your-app.vercel.app/*to allow all subpages.
Act IV: The Ultimate Boss Fight — Firebase auth/cancelled-popup-request
With places working, I tried logging in. The Google Sign-In popup opened, closed, and died with a cryptic error in the DevTools console:
Sign in error: Firebase: Error (auth/cancelled-popup-request)
getProjectConfig?key=AIzaSyXXXX_REDACTED Failed to load resource: 403 Forbidden
Unable to verify that the app domain is authorized
I was trapped. I checked the Firebase Authorized Domains console—my custom domain was in the list. I checked API restrictions, checked all active Firebase service checkmarks in Google Cloud... and it still threw the 403.
Then came the epiphany.
When you trigger signInWithPopup, Firebase opens a popup window. That popup window does not run on localhost or vercel.app. It runs on Google's own Firebase auth handler domain!
https://your-project-id.firebaseapp.com/__/auth/handler
Because I restricted my Firebase key to only allow requests from localhost and vercel.app, the key blocked its own Google redirect window from running!
I went back into the Google Cloud Console Website Restrictions for my Firebase key and added a third, critical allowed referrer:
https://your-project-id.firebaseapp.com/*
I clicked Save. Checked my browser. Sign-in succeeded instantly.
Act V: Database Isolation & Pragmatic Access Controls
The final step was securing our database. Initially configured in wide-open test mode, the database needed a clear, secure scoping system.
Our application has a unique requirement: both guest visitors (who are allocated a small number of free generations) and signed-in accounts must be able to record their travel plans. However, if we lock the entire database down to authenticated-only users, anonymous guests would experience errors. Conversely, if we leave it fully open, any user could easily reset their quota tracking metrics via client-side scripts.
The Solution: Rather than introducing enterprise-grade backend complexity, I settled on a pragmatic, hybrid database control model:
- Locked Quota Tracking: Completely shut down client-side read/write access to our usage and quota-tracking layers. This forces all tracking, rate-limiting validation, and counter updates to execute strictly on the server-side backend environment, using server credentials that naturally bypass rules.
- Segmented Data Intake: Kept transaction access confined strictly to the specific data collections needed by client components to record itineraries, while enforcing a secure, restrictive catch-all default block on everything else.
While separating public-facing data collections from server-only collections is a trade-off tailored for low-traffic hobby deployments, this layout ensures all critical usage constraints are securely walled off from direct browser manipulation.
The Lessons
- Client-side env vars are public: Anything with
VITE_orNEXT_PUBLIC_is public. Treat it like a public URL, not a secret. - Referrer Restrictions need the Handler: If you restrict a Firebase API key, you must whitelist your
.firebaseapp.comauth domain, or your login popup will break. - Prepaid is a great shield: Prepaying a small balance is a highly effective way to prevent runaway post-paid billing surprises for hobby projects.
- Server routes are your proxies: Proxy paid APIs through a server route to get rate limiting, authentication, and secure secret handling.
Sometimes you have to get burned to build better. But going through the security Whack-a-Mole made me a much stronger developer.
Now go check your VITE_ and NEXT_PUBLIC_ variables. I'll wait.
Related Modules
Building AdminForge: A Zero-Config Admin Panel and AI Orchestration Layer for Next.js
The story of building AdminForge — an open-source framework that auto-generates admin dashboards, REST APIs, and AI agent interfaces from a single TypeScript config file.
Securing Spring Boot Applications with OAuth2 and JWT
A comprehensive guide to implementing secure authentication and authorization in Spring Boot applications using OAuth2 and JWT.