Your API keys are in the browser
You asked the agent to add AI features, or email, or payments. It needed a key, so it put one in your .env file, prefixed it the way the framework wanted, and everything started working. That prefix is the whole problem. In a Vite project, every variable starting with VITE_ is inlined into the JavaScript at build time. In Next.js it is NEXT_PUBLIC_. The file stays on your machine, but its value ships to every visitor.
Nothing about this looks broken. The app works, the calls go through, the agent reported success. The key is simply readable by anyone who opens your site and knows where to look, and a lot of people do look, because scanning public bundles for keys is automated.
Checking takes two minutes. Open your live site, open the browser devtools, go to the Sources tab and search across all files for sk-, then for sk_live_, then for service_role, then for re_. Then open the Network tab, reload the page, use the app, and see whether any request goes straight from the browser to a provider like api.openai.com or api.anthropic.com. If it does, look at its request headers: the Authorization header is your key, and it is sitting in the visitor's browser.
Not every key in the bundle is a leak. Some are designed to be public: a Stripe publishable key starting with pk_ and a Supabase anon key both belong in the frontend, and Stripe and Supabase say so themselves. The anon key is only as safe as your row level security policies, which is a separate check. The ones that matter here are the secret ones: an OpenAI or Anthropic key starting with sk-, a Stripe secret key starting with sk_live_, a Supabase service_role key, a Resend or SendGrid key, and any webhook signing secret or admin token.
If you find one, rotate it before you fix anything else. Go to the provider dashboard, issue a new key, revoke the old one, and treat the old one as compromised even if your usage looks normal, because rotating is cheap and a stolen key is usually spent quietly at first. Then look at billing and usage history for anything you cannot explain, and set a hard spending limit while you are there. If the key was ever committed to a public repository, removing it from the current code is not enough, since git keeps it in the history of every clone.
The real fix is that the key never reaches the browser at all. The call moves to a server route: an API route, a serverless function, a Supabase edge function, whatever your stack already has. The browser calls your route, your route holds the key and calls the provider. That is the small part. The part people skip is that this route is now a public endpoint, so it needs to check who is calling it and how often. Without that you have not protected your credits, you have only moved them behind a URL that anyone can call in a loop.
That is the shape of the work: find every secret in the bundle, rotate all of them, move each call behind a route, then put authentication and a rate limit on those routes. A single key with one endpoint is an evening. A product that grew for a few months with an agent adding integrations along the way is usually more, mostly because finding all of them is slower than fixing any one.