Guide
How AI-built apps leak API keys — and how to stop it
Why secret keys end up in browser bundles and Git history in AI-generated apps, how to tell publishable keys from secret ones, and how to fix and rotate them.
Key takeaways
- 01Anything bundled for the browser is public, including variables prefixed NEXT_PUBLIC_, VITE_ or EXPO_PUBLIC_.
- 02Publishable keys are designed to be public; secret keys never are. Know which one you’re holding.
- 03The fix is architectural: secrets belong in server-side code that the browser calls.
- 04A key that was ever exposed must be rotated. Removing it from the code doesn’t un-leak it.
Exposed API keys are among the most common — and most expensive — mistakes in AI-built applications. A leaked payment key can be used to issue refunds or read customer data. A leaked AI provider key can run up a large bill overnight. A leaked database admin key bypasses every access rule you set.
The good news: this is one of the easiest classes of problem to find and fix, once you know where keys hide.
How keys end up public
Inline keys in client components
The fastest way for an assistant to make an integration work is to paste the key where it’s used. If that file runs in the browser, the key ships to every visitor.
"use client";
import Stripe from "stripe";
// This file is bundled into browser JavaScript.
const stripe = new Stripe("sk_live_…");
In a framework like Next.js, the "use client" directive is the giveaway. In a Vite or Create React App project, almost everything in src/ ends up in the browser.
Public environment variable prefixes
Moving a key into an environment variable is the right instinct — but frameworks deliberately expose variables with certain prefixes to the browser:
| Framework | Browser-exposed prefix |
|---|---|
| Next.js | NEXT_PUBLIC_ |
| Vite | VITE_ |
| Expo | EXPO_PUBLIC_ |
| Create React App | REACT_APP_ |
A secret stored as NEXT_PUBLIC_OPENAI_API_KEY is still public. The prefix tells the build tool to inline its value into your JavaScript. If an assistant adds a prefix to “fix” an undefined variable error, it may have just published your secret.
Committed configuration files
.env files, config.json, service account files and mobile build configs are easy to commit accidentally. Deleting them later doesn’t help: they remain in Git history and in every clone and fork made in the meantime.
Logs, errors and mobile bundles
Keys also leak through verbose error messages, debug logs shipped to the browser console, and mobile apps — which can be unpacked to reveal embedded strings.
Publishable keys vs. secret keys
Not every key in your frontend is a problem. Many services issue two kinds:
- Publishable keys are designed to be public. A Stripe
pk_key can only perform limited actions, such as creating payment tokens. A Supabase anon key is meant to ship in your frontend — the database’s Row Level Security policies decide what it can access. Firebase web configuration values are public by design; security rules do the protecting. - Secret keys grant broad authority: a Stripe
sk_key, a Supabaseservice_rolekey, an AI provider API key, cloud credentials, database connection strings. These must never reach the browser.
The dangerous mistake is swapping one for the other — for example, using a service-role key in the frontend because the anon key “didn’t have permission”. That error message was the security model working.
How to find exposed keys
- Search your source. Look for secret-looking prefixes and names:
sk_live,sk_test,service_role,secret,api_key,private_key,-----BEGIN. - Audit public variables. List every variable with a browser-exposed prefix and confirm each value is safe to publish.
- Search the built output. Build your app and search the output directory (for example
.next/staticordist) for the same patterns. This catches keys that reach the bundle indirectly. - Check Git history.
git log --all -p -S "sk_live"finds commits that added or removed a string. Repeat for other key patterns. - Run a local inspection. VibeTect is built to check your project on your own machine for exposed secrets — including keys in client-side code and credentials in configuration files — without uploading the code it’s inspecting.
How to fix it properly
1. Move secret operations to the server
The browser should ask your server to do privileged work; the server holds the key.
// app/api/checkout/route.ts — runs only on the server
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const { price } = await req.json();
const session = await stripe.checkout.sessions.create({
mode: "payment",
line_items: [{ price, quantity: 1 }],
success_url: `${process.env.APP_URL}/thanks`,
});
return Response.json({ url: session.url });
}
The client component then calls /api/checkout instead of Stripe directly. Validate the incoming price against an allow-list so users can’t choose arbitrary prices.
2. Rotate every exposed key
If a secret was ever deployed, committed or shared, generate a new one in the provider’s dashboard and revoke the old one. Assume the old key has been copied — automated scanners search public code for credentials continuously.
3. Reduce the blast radius
Where providers allow it, restrict keys: limit them to specific domains, IP addresses or permissions; set spending limits on AI and cloud APIs; use separate keys for development and production.
4. Prevent it next time
Add .env and similar files to .gitignore, enable secret scanning on your repository host, and run a local check before every deploy. When you ask an assistant to add an integration, say so explicitly: “Keep the API key on the server and never expose it to the client.”
Exposed secrets are one of fifteen items on our vibe coding security checklist. They’re also the one most worth checking today.