The key your app already ships to the browser
The concrete risk is not theoretical: if a real secret is wired into your frontend, it is already sitting in the JavaScript your public URL serves to every visitor. A live OpenAI key, a Stripe secret key, a Supabase service_role key, a Google Maps key with no restriction, hardcoded into client code or baked in at build time, lands in the bundle the browser downloads. From there, reading it takes a logged-out visitor with DevTools open. No exploit, no login, no special access.
The useful part is that you can find it the same way anyone else would, in about five minutes, against your own public URL. This note is the hands-on method: the exact DevTools steps to run, what each finding means, and how to tell a key that is meant to be public from one that has to move server-side and be rotated.
Do this against your own app only. Open it in a normal browser session, logged out, the way a first-time visitor arrives.
- Observable: any secret-shaped string in a loaded .js file, or a key sent as a query parameter or header in a network request. If your browser can see it, so can anyone.
- Only possible until you check: whether that string is actually a live secret that grants real access. Finding it is step one; deciding what it is comes next.
Why AI-built apps end up here
The split between "this belongs in the browser" and "this must stay on the server" is one of the easiest things to get wrong fast. AI builders optimize for a working feature. When the prompt is "call the OpenAI API and show the result," the quickest generated path is often a direct call from the frontend with the key inlined, because that works on the first try and the UI lights up. The app looks finished.
A few patterns push secrets into the client without anyone deciding to:
The fix loop, where someone asks "should this key be reachable from a browser," is the step that does not happen automatically.
- A key pasted directly into a component or fetch call so the demo works, then never moved.
- A build-time variable that gets inlined into the bundle. A prefix like VITE_ or NEXT_PUBLIC_ tells the bundler to embed the value in client code. If a real secret was named that way, it ships to the browser. The mechanism is covered in our note on Vite env variables exposed in the client bundle; this note is the method to catch it.
- A deploy preview that becomes the launch URL before anyone audits what the build actually contains.
The five-minute sweep: find an API key in the browser network tab
Open your public URL in Chrome or Firefox, logged out, then open DevTools (F12, or right-click and Inspect). Run these four passes.
1. Network tab. Open it, then reload the page so requests are captured. Filter to JS to see the script files, then also look at Fetch/XHR for live API calls. For each request, check the request URL for a key passed as a query parameter (something like ?api_key=... or ?key=AIza... in the URL), check the request headers for an Authorization or apikey header carrying a token, and skim response bodies for keys echoed back. A secret in a URL is the worst case, because URLs get logged in proxies, browser history, and server access logs.
2. Sources / page source. The .js files listed under Sources (or "view page source" plus the linked scripts) are your client bundle. This is the exact code shipped to every visitor. It is minified, which is not protection, only inconvenience. If source maps are also public, the code becomes fully readable; see our note on source maps and public exposure.
3. Global search across all loaded sources. In Chrome DevTools press Cmd-Shift-F (Ctrl-Shift-F on Windows/Linux) to search every loaded source at once. Search for these telltale patterns one at a time: sk_ and pk_live (Stripe), AIza (Google API keys), service_role and eyJ (Supabase service key and JWT prefix), Bearer, api_key, apikey, secret, token, and your provider names: openai, anthropic, stripe, supabase. A hit inside a .js file means that string is in the bundle.
4. Authenticated requests from the client. Watch any Fetch/XHR that sends an Authorization header. If the frontend is attaching a long-lived secret to call a third-party API directly, that secret is in the browser. A request that calls your own backend, which then talks to the third party, is the safe shape.
What the results mean:
- Suspicious: a live secret key (sk_, service_role, a provider API key, a long-lived token) appears in a .js file, a request URL, or a client-set header. Treat it as exposed.
- Safer: the only key-shaped strings you find are ones designed to be public, and live calls to third-party APIs go through your own server rather than straight from the browser.
For each finding: public by design, or a real secret
Not every key in the browser is a mistake. Some are meant to be there. The decision for each finding is the same question: is this key designed to be public, or is it a secret that leaked?
Designed to be public (finding it is expected, no action beyond normal restrictions):
A real secret that must move (treat as a launch blocker):
If you are unsure which category a key is in, check the provider's docs for that key type. Publishable and anon keys are documented as client-safe; secret and service keys are documented as server-only.
- A Stripe publishable key (pk_live or pk_test). It identifies your account to Stripe.js and cannot move money on its own.
- The Supabase anon key. The browser needs it to talk to Supabase, and it is only as safe as your Row Level Security policies. Its presence is normal; what matters is that RLS is enabled so the key alone does not return private rows.
- A Google Maps or similar client key that is meant for browser use but should be locked down by HTTP referrer and API restrictions in the provider console.
- A Stripe secret key (sk_live or sk_test), an OpenAI or Anthropic API key, a Supabase service_role key, or any token that grants write or admin access.
- The fix is two steps, and both are required. First, move the call server-side: the secret lives in a server environment variable, and the browser calls your backend, which makes the third-party call. Second, rotate the key, because the old value was public and must be assumed compromised. Moving it without rotating leaves the leaked value still valid.
Where VibeCodeGuard fits, and what the sweep does not prove
VibeCodeGuard's Launch Check runs this same bundle inspection from outside the browser. It fetches your public surface, scans the served JavaScript for secret-shaped strings, and flags the matches with severity and fix direction, so you get the clean-session check as a repeatable step on every deploy instead of a manual DevTools sweep you have to remember to do by hand. The logic is the same as the method above; the scan just makes it routine.
What neither the manual sweep nor the scan can do is confirm whether a found key is live, or prove that a key it did not surface is truly safe. A string that looks like a secret might be a revoked test value; a custom-named token might be a real secret that no pattern matched. And a clean result only covers what the public surface serves, not what your server-side code or private endpoints do with secrets.
Next step: run the four-pass sweep on your own URL today, and for every real secret you find, move it server-side and rotate it. Then run a focused Launch Check before sharing the URL publicly, so an exposed key shows up in your scan and not in someone else's.
Finding nothing is one signal, not a guarantee. The sweep proves what a visitor can read from the bundle right now; it does not prove a key is unused, does not test whether a found key still works, and does not review server-side handling. The principle holds regardless: anything the browser can load, anyone can read, and minification is not protection.
> launch check
Scan the public surface before launch.
Get severity, evidence, and practical fix guidance for the checks VibeCodeGuard can run from the outside.