The Secrets Management Mistake We Find in 70% of Vibe-Coded Apps
The four ways AI builders leak API keys, Stripe tokens, and database credentials, each with the fix we ship when WitsCode audits a vibe-coded app.
Roughly seven out of every ten vibe-coded apps we audit at WitsCode have at least one production secret exposed. Sometimes it is a Stripe live key sitting in a React component. Sometimes it is a Supabase service role key compiled into a Next.js bundle by a well-meaning NEXT_PUBLIC_ prefix. Sometimes it is a .env file quietly living in the public GitHub repo that the founder forgot was public. The AI builder that generated the app almost never flagged any of it.
The pattern is not random. Lovable, v0, Bolt, Cursor, and Claude Code each have a characteristic way of getting this wrong, and the four patterns below are what we pull out of audit after audit. This post walks through each one with the actual fix we ship, plus the part that most of the Google results for "ai app secrets management" skip entirely: not every key in client code is actually a secret, Next.js has a prefix that turns env vars into a footgun, and adding .env to .gitignore does nothing if you already committed it last week.
Pattern one: Firebase config in the client bundle
The first pattern looks the scariest and is usually the least dangerous, which is why we start with it. Almost every Firebase app we audit has something like this in the repo, committed openly:
// src/lib/firebase.js
import { initializeApp } from "firebase/app";
const firebaseConfig = {
apiKey: "AIzaSyB3xK9pQrS7vW2nL4mJ8hF6gT1dY5cX0eZ",
authDomain: "my-saas-app.firebaseapp.com",
projectId: "my-saas-app",
storageBucket: "my-saas-app.appspot.com",
messagingSenderId: "284910573621",
appId: "1:284910573621:web:a7b8c9d0e1f2",
};
export const app = initializeApp(firebaseConfig);
A founder reads a Twitter thread about "never put API keys in client code," panics, opens a ticket, and expects us to move the config to a server route. We do not. Firebase's web config, including the apiKey, is designed to be public. Google's own documentation says so explicitly. The apiKey here does not authenticate anything. It identifies the Firebase project to the Google APIs so the SDK knows which project to talk to. The actual gatekeeping happens in two places we need to look at instead: Firestore Security Rules and Storage Rules.
The dangerous thing we usually find is not the config. It is the rules tab in the Firebase Console showing this:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /{document=**} {
allow read, write: if true;
}
}
}
That lets anyone on the internet read and write every document in the database. The fix is not hiding the Firebase config. The fix is writing real rules that tie access to request.auth.uid:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /users/{userId} {
allow read, write: if request.auth != null
&& request.auth.uid == userId;
}
match /orders/{orderId} {
allow read: if request.auth != null
&& resource.data.userId == request.auth.uid;
allow create: if request.auth != null
&& request.resource.data.userId == request.auth.uid;
}
}
}
If you take one thing from this pattern, it is that a leaked Firebase config is a non-event when rules are correct, and a catastrophic data leak when they are not. We still add the config to environment variables because it keeps the repo portable across staging and production, but we no longer pretend we are hiding a secret by doing it.
Pattern two: real secrets shipped to the client via NEXT_PUBLIC_
The second pattern is what founders actually need to panic about, and it is almost always introduced by the AI builder trying to be helpful. The sequence looks like this. A vibe coder asks Lovable or Cursor to add OpenAI chat to their Next.js app. The AI writes a component that calls the OpenAI API directly from the browser. The first run fails because process.env.OPENAI_API_KEY is undefined in the client. The AI's next turn fixes the undefined by renaming the variable to NEXT_PUBLIC_OPENAI_API_KEY. The build succeeds. The preview works. The key is now baked into the JavaScript bundle and served to every visitor.
The broken code looks like this:
// app/chat/page.tsx
"use client";
import { useState } from "react";
export default function Chat() {
const [reply, setReply] = useState("");
async function send(prompt: string) {
const res = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.NEXT_PUBLIC_OPENAI_API_KEY}`,
},
body: JSON.stringify({
model: "gpt-4o-mini",
messages: [{ role: "user", content: prompt }],
}),
});
const data = await res.json();
setReply(data.choices[0].message.content);
}
return <button onClick={() => send("hi")}>Chat</button>;
}
The Next.js build inlines any variable prefixed with NEXT_PUBLIC_ directly into the client bundle at compile time. It is not runtime lookup. The literal string of your key ends up in a .js file that anyone can view by opening DevTools and searching for sk-. The fix is to move the call to a server route so the key never leaves the server:
// app/api/chat/route.ts
import OpenAI from "openai";
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
export async function POST(req: Request) {
const { prompt } = await req.json();
const completion = await openai.chat.completions.create({
model: "gpt-4o-mini",
messages: [{ role: "user", content: prompt }],
});
return Response.json({ reply: completion.choices[0].message.content });
}
// app/chat/page.tsx
"use client";
import { useState } from "react";
export default function Chat() {
const [reply, setReply] = useState("");
async function send(prompt: string) {
const res = await fetch("/api/chat", {
method: "POST",
body: JSON.stringify({ prompt }),
});
const data = await res.json();
setReply(data.reply);
}
return <button onClick={() => send("hi")}>Chat</button>;
}
The same rewrite applies to Stripe secret keys, Resend API keys, Anthropic keys, and anything whose name contains the word "secret." The rule we enforce on every audit is that NEXT_PUBLIC_ is reserved for values you would be happy to see printed on the homepage. If in doubt, assume it will be, because once a build ships, that key is in every user's browser and every CDN cache forever.
Pattern three: the .env file that was committed before .gitignore was written
The third pattern is the one that keeps us up at night, because the fix is not only code. By the time we see it, the damage has usually already happened. The shape is always the same. A founder runs npx create-next-app, creates a .env file, pastes in their Supabase service role key, Stripe secret key, and Resend API key, then runs git add . before they remembered to write a .gitignore. The file goes to GitHub. If the repo is public, secret scrapers find it within minutes. If it is private, it might sit undetected for months until a contractor is added to the org.
The reflexive fix people reach for is this:
echo ".env" >> .gitignore
git rm --cached .env
git commit -m "stop tracking .env"
git push
That stops tracking the file going forward. It does absolutely nothing about the copy of the file that is already baked into the commit history. Anyone who clones the repo, or reads the GitHub web UI at the old commit SHA, still has the keys. GitHub's secret scanning has probably already alerted you, and if the repo is public, somebody's bot has definitely already grabbed them.
The correct response has three parts that must happen in this order. First, rotate every secret that was ever in that file, in the provider's dashboard, right now, before anything else. Assume they are compromised because they are. Second, rewrite history to remove the file entirely using git filter-repo or BFG Repo-Cleaner:
# install git-filter-repo first
pip install git-filter-repo
# remove .env from every commit ever
git filter-repo --path .env --invert-paths
# force push (destructive, coordinate with team)
git push origin --force --all
git push origin --force --tags
Third, prevent recurrence by installing a pre-commit hook that blocks secrets from ever being committed again. We use gitleaks because it is a single Go binary with sensible defaults and zero config required:
# install
brew install gitleaks # or: go install github.com/gitleaks/gitleaks/v8@latest
# add to husky or git hooks
npx husky add .husky/pre-commit "gitleaks protect --staged --verbose"
On top of that we turn on GitHub Secret Scanning with Push Protection for the repo. It is free for public repos and free on private repos for accounts on most paid plans. It blocks a git push at the server when it detects a known Stripe, AWS, Supabase, OpenAI, or Anthropic token pattern, which gives you a second line of defense if the pre-commit hook gets bypassed. We also move production secrets into a dedicated secret manager, usually Doppler for teams that want a CLI-first developer experience or AWS Secrets Manager for teams already on AWS. A secret manager is not just about storage. It lets you rotate a key without redeploying, which is the difference between a five-minute incident and a five-hour incident.
Pattern four: hard-coded tokens and fallback strings
The fourth pattern is the one that looks most innocent and is the easiest for an AI to generate. Cursor and Claude Code in particular like to write "defensive" code that falls back to a literal string when an environment variable is missing:
// lib/stripe.ts
import Stripe from "stripe";
const STRIPE_KEY = process.env.STRIPE_SECRET_KEY
|| "sk_live_51NxKq2A9bZ7cPqR4mT8vW3yL6fJ...";
export const stripe = new Stripe(STRIPE_KEY, { apiVersion: "2024-06-20" });
The fallback is presented by the AI as "in case the env var is not set during development." In practice the string is the production key, copy-pasted from a terminal session where the founder ran echo $STRIPE_SECRET_KEY to debug something earlier. Now it is in the repo. Same story for Supabase service role keys that get inlined "for simplicity" when the AI cannot figure out why process.env is undefined in an edge function.
The fix has two halves. The mechanical half is to remove the fallback and fail loudly when the env var is missing, so the next deploy surfaces the misconfiguration instead of silently running on a leaked key:
// lib/stripe.ts
import Stripe from "stripe";
const key = process.env.STRIPE_SECRET_KEY;
if (!key) {
throw new Error("STRIPE_SECRET_KEY is not set");
}
export const stripe = new Stripe(key, { apiVersion: "2024-06-20" });
The cultural half is to rotate any key that was ever in the fallback. Even if the repo is private, that key has been through every developer's laptop, every CI log, and every clone of the repo that ever happened. Treat it as compromised and issue a new one. Then wire Doppler or whichever secret manager you chose into local dev so developers never have to paste a key into a file again. A single doppler run -- npm run dev injects the full environment at runtime, which means the temptation to hardcode disappears because there is nothing to hardcode.
What defense in depth actually looks like for a vibe-coded app
Any one of the fixes above is a patch. The apps we finish do not rely on any single one, because any single one can be bypassed by a tired founder on a Friday. The layered setup we leave behind on every secrets audit looks like this. Pre-commit gitleaks catches a secret before it leaves the laptop. GitHub Secret Scanning with Push Protection catches it if the hook is skipped. A secret manager holds the canonical copy and injects at runtime, so the .env file in the repo is empty or nonexistent. Production keys are scoped to the smallest permission set that works, so a leak of the Stripe publishable key is genuinely boring and a leak of the Stripe restricted key limits blast radius. And every provider that supports it has rotation scheduled, so even an undetected leak has a finite shelf life.
The hardest part of this is not technical. It is convincing a vibe coder who shipped their product in a weekend that three extra layers of tooling are worth the friction. Our argument is always the same: the first time a scraper pulls your Stripe live key out of a public bundle and runs ten thousand dollars of test charges at three in the morning, every one of these layers pays for itself, and the one you were missing is the one you wish you had installed first.
WitsCode Secrets Audit
If you have shipped a Lovable, v0, Bolt, or Cursor app and you are not one hundred percent sure where every API key in it lives, we run a fixed-scope Secrets Audit. We scan the repo, the deployed bundle, and the full git history for exposed keys. We rotate the ones that are compromised, coordinating with Stripe, Supabase, OpenAI, and whoever else is involved. We install gitleaks pre-commit, turn on GitHub Secret Scanning, and migrate your production secrets into Doppler or the manager of your choice. You get a report of what we found, what we rotated, and the hooks you need to keep it clean. Book a WitsCode Secrets Audit ->
Get weekly field notes.
Practical writing on shipping products, straight to your inbox. No spam.
Need help with this?
MVP Development
We design and build web apps, MVPs, and SaaS products. Talk to us about what you are working on.
Talk to usWant to discuss vibe coders for your business?
Start a project and we'll talk through where you are, what's working, and the highest-leverage moves for the next 90 days.