
Two posts from builders this week describe the same mistake from two directions. In one, a developer opened DevTools on their own Replit app and found their image-generation API key sitting in the JavaScript bundle, then scanned 978 other AI-built apps and found the same problem in 37 of them, about 1 in 26. In the other, a developer put a decoy MCP endpoint on a site and watched 7 different AI clients connect within 7 minutes, 4 of which asked for the full list of tools.
Both are cases of something you built being more visible than you think. Neither needs an attacker to be clever. Below: how each leak happens, why the usual checks miss it, how to check your own app in ten minutes, and the fixes that actually close it.
Leak 1: the API key in your JavaScript bundle
The developer's repo scanner said "no secrets found", and it was right in its own terms: the key never touched git. It came in through a VITE_ environment variable at build time.
That is how frontend build tools are designed to work. Vite exposes any environment variable prefixed with VITE_ to your client code, and the build replaces it with the literal value. Next.js does the same for NEXT_PUBLIC_, Create React App for REACT_APP_, and Expo for EXPO_PUBLIC_. The prefix is not a security setting. It is an instruction to ship the value to every visitor's browser.
So the key ends up in a file that anyone can download, and anyone who finds it can call the API on your account. For an image or video model that is a direct line to your bill.
Why your secret scanner missed it
Most secret scanning looks at the source repository: committed files, commit history, sometimes pull requests. A key that lives in a .env file that is correctly gitignored, and is then inlined at build time, never appears in any of those places. The scanner is clean, the repo is clean, and the shipped artifact is not.
AI coding tools make this more common, not less. Ask an agent to "call the image API from the app" and the fastest working answer is often a client-side fetch with the key in a public environment variable. It works on the first try, which is exactly why nobody questions it.
Check your own app in ten minutes
- Open your deployed app, open DevTools, go to Sources (or Debugger), and search across all files for the start of your keys: "sk-", "AIza", "key", the provider's name, or the variable name itself.
- Search your built output locally too: run your production build and search the output folder (dist, .next/static, build) for the same strings.
- List every environment variable with a public prefix (VITE_, NEXT_PUBLIC_, REACT_APP_, EXPO_PUBLIC_) and ask of each one: would I be fine posting this value on X? If not, it should not have the prefix.
- Check the network tab while you use the app. Any request that goes straight from the browser to a paid AI API with an authorization header is a key in the client.
Fix it properly
- Move the call server-side. Put the API call in a backend route, serverless function or edge function that holds the key. The browser calls your route; your route calls the provider.
- Rotate the leaked key. Removing it from the next build does not help: the old bundle may be cached, archived or already copied. Create a new key and revoke the old one.
- Restrict what remains public. Some keys are designed to be public (for example, Google browser keys restricted to your domain, or a Supabase anon key protected by row-level security). Keep those restricted and rely on the restriction, not on secrecy.
- Cap the damage. Set spending limits or budget alerts with each AI provider, and rate-limit your own backend route so one visitor cannot run up the bill.
What a safe proxy route looks like
Moving the call server-side only helps if the new route is not itself an open door. A route that takes any prompt from anyone and forwards it to your image model has the same bill problem, just one hop later. A minimal safe version does four things before it calls the provider:
- Checks that the request comes from a signed-in user (or, for a public demo, a valid session or captcha token).
- Validates the input: expected fields only, a maximum prompt length, allowed sizes or models.
- Applies a per-user quota and a global rate limit, so one account or one script cannot drain your budget.
- Calls the provider with the server-held key and returns only what the client needs, never the raw provider response with headers.
None of this is exotic, and most frameworks have middleware for each step. The point is to decide it on purpose rather than discover it on your invoice.
Give your AI coding tool the rule
If an agent writes most of your code, the cheapest fix is to tell it once. Add a short rule to the project instructions your tool reads (CLAUDE.md, AGENTS.md or the equivalent): paid API keys never go in variables with a public prefix, and every call to a paid AI API goes through a server route. Agents follow project rules far more reliably than they infer security intent from a feature request.
Then back the rule with a check that does not depend on anyone remembering it: a small script in your deploy step that searches the production build output for known key prefixes and fails the deploy if it finds one. It takes a few lines, and it catches the case where a new dependency, a copied snippet or a tired late-night prompt reintroduces the leak.
Leak 2: the MCP endpoint everyone can find
The second post is about servers rather than bundles. The developer exposed a decoy MCP endpoint at /mcp and logged who showed up. Within 7 minutes, 7 different clients connected: mcpbeat, AgentTrust-Monitor, FixterMonitoring, AgentRankProbe, Clono, an unnamed client on a German VPS, and coda-agent-runtime running on AWS. Four of them called tools/list.
The author's read is measured: most of these look like directories and monitors, not attackers. But the lesson does not depend on intent. Anything exposed at /mcp gets found and its tool list read within minutes. If any tool writes data and the endpoint has no authentication, the next client might call it instead of just listing it.
Why MCP endpoints are different from a normal API
A normal API is a list of routes you designed for programs you expect. An MCP server is a self-describing menu: tools/list tells any client exactly what it can do and how to call it, by design, so that agents can use it without documentation. That is what makes MCP useful, and it is also what makes an unauthenticated server an open invitation. The discovery step that a human attacker would have to do by hand is built into the protocol.
Builders often stand up an MCP server to test something with Claude or another client, deploy it next to their app, and forget it is public. The decoy experiment shows how short "forgotten" is: minutes, not weeks.
Lock down your MCP server
- Require authentication. The MCP specification includes an authorization flow based on OAuth. Use it, or at minimum put the endpoint behind a token you control.
- Keep local servers local. If a server is only for your own agent, run it over stdio or bind it to localhost instead of a public URL.
- Split read and write. Expose read-only tools publicly if you must, and keep any tool that writes, deletes, sends or spends behind auth and a confirmation step.
- Log and rate-limit tools/list and tool calls. You want to know who is enumerating your tools, and you want a ceiling on how often anyone can call them.
Test your own server from the outside
The quickest check mirrors what those clients did. From a network that is not your own (a phone hotspot or a cloud shell is enough), point an MCP client, or a plain JSON-RPC request, at your public URL and ask for tools/list without any credentials. If you get a tool list back, so does everyone else. Then try calling one harmless tool the same way. If that works too, every tool on the list is callable by anyone who finds the URL.
Repeat the check after every deploy that touches the server. Auth middleware is easy to drop by accident when routes move.
The pattern behind both leaks
In both cases, the thing that leaked was never meant to be secret from the builder's point of view. It was just "the app" or "my server". The bundle is public because browsers need it. The MCP tool list is public because agents need it. The leak happens at the boundary between "something I use" and "something anyone can fetch", and AI tools speed up crossing that boundary without anyone deciding to.
The practical fix is a habit: before you ship, ask what a stranger can download or list, and check it the way a stranger would. Open the bundle. Call tools/list from outside your network. It takes minutes, and it is the cheapest security review you will ever do. If you install third-party agent skills, the same idea applies to them: scan a skill before you install it, and consider sandboxing what your agent can reach.
Practical takeaways
- A public env prefix (VITE_, NEXT_PUBLIC_, REACT_APP_, EXPO_PUBLIC_) ships the value to every visitor. Never put a paid API key behind one.
- A clean repo scan does not mean a clean bundle. Search the built output and the deployed app.
- Move paid AI calls server-side, rotate any key that ever shipped, and set spending caps.
- An exposed /mcp endpoint is found and enumerated within minutes. Authenticate it or keep it local.
- Keep write tools behind auth and confirmation, even if read tools are public.
- Before every launch, look at your app the way a stranger can: what can they download, and what can they list?
Conclusion
Neither of these leaks needs a sophisticated attacker. One needs a curious visitor with DevTools, and the other needs a directory crawler doing exactly what it was built to do. Both are fixed by the same ten-minute habit of checking what is public before someone else does.
For a weekly note on what is actually working for builders shipping with AI, subscribe to the HelloBuilder newsletter.


