VibeOps Club / Guides
API keys and secrets in apps built with Cursor, Lovable or Claude Code: where they belong and how to check
An AI agent puts a key wherever the code works first, and in an app that is mostly frontend, that place is often the browser. Below: which keys may be public, which must stay on the server, and a 15-minute leak check for an existing app.
In October 2025 Escape analyzed more than 5,600 public vibe-coded apps built on Lovable, Base44, Create.xyz, Bolt.new and Vibe Studio and found over 400 exposed secrets, alongside 2,000+ vulnerabilities and 175 cases of exposed personal data (case summary).
Publishable keys and secret keys
A publishable key identifies your project and is designed to be embedded in a web page or mobile app. A secret key acts on your behalf with elevated rights and must never leave the server.
Designed to be public
- Supabase publishable key (
sb_publishable_...) and the legacyanonkey. They are safe in the browser only if Row Level Security is enabled on every table and your policies are correct. - Stripe publishable key (
pk_live_...,pk_test_...). It can create tokens and payment methods but cannot create charges or read account data.
Must stay on the server
- Supabase secret key (
sb_secret_...) and the legacyservice_rolekey. Both run as theservice_rolePostgres role, which bypasses RLS completely. Supabase answers HTTP 401 when a new secret key is sent from a browser, which catches some mistakes, but that check is a safety net and does not make shipping either key acceptable. - Stripe secret and restricted keys (
sk_...,rk_...). - OpenAI and Anthropic API keys (
sk-...,sk-ant-...). These providers have no publishable variant. Anyone who has the key can send requests billed to your account. - Database connection strings, webhook signing secrets, and tokens for hosting and infrastructure (Railway, Vercel, Cloudflare, AWS and so on).
A publishable key is safe only when the access rules behind it are correct. Moltbook shipped its Supabase key in client-side JavaScript with RLS disabled, and in January 2026 Wiz found the database fully readable without authentication: about 1.5 million API keys, ~35,000 emails and 4,060 private messages. The team fixed it within hours of disclosure (Moltbook case). If your app talks to Supabase directly from the browser, RLS policies are your backend, and you should test them as described in the production checklist.
Everything the browser receives is public
Any value the JavaScript bundle contains can be read by anyone who opens the site, and minification does not hide strings. It is visible in DevTools under Sources, in the downloaded .js files, and in the Network tab when the value is sent in a request header. If your deployment also publishes source maps (.map files), the original, readable source is available as well.
Frameworks make this easy to get wrong because a naming prefix decides whether an environment variable is inlined into the client bundle at build time:
NEXT_PUBLIC_in Next.js. The value is inlined into JavaScript sent to the browser.VITE_in Vite, which covers many Lovable and Bolt projects. Vite's own docs warn that these variables should not contain API keys.EXPO_PUBLIC_in Expo. The value is visible in plain text in the compiled mobile app.
If client code reads OPENAI_API_KEY and gets undefined, renaming it to NEXT_PUBLIC_OPENAI_API_KEY fixes the error and publishes the key. Treat any secret with one of these prefixes as leaked.
Where secret keys belong
If a feature needs a secret key, the call has to happen on a server you control: an API route, a serverless function, a Supabase Edge Function, or a Cloudflare Worker. The browser calls your endpoint, and the endpoint checks who is asking before it uses the key.
// app/api/summarize/route.js (runs on the server)
export async function POST(req) {
const user = await getSessionUser(req); // your auth
if (!user) return new Response('Unauthorized', { status: 401 });
if (await overLimit(user.id)) return new Response('Too many requests', { status: 429 });
if (!user.hasActiveSubscription) return new Response('Payment required', { status: 402 });
const { text } = await req.json();
const result = await summarize(text, process.env.OPENAI_API_KEY); // no NEXT_PUBLIC_
return Response.json(result);
}
The endpoint also needs authentication, a per-user rate limit and a server-side subscription check. On March 15, 2025, the founder of EnrichLead posted that his SaaS was built with Cursor with "zero hand written code". Two days later he wrote that people had "maxed out usage on api keys" and were "bypassing the subscription". On March 20 he shut it down. Commentators attributed it to keys and subscription checks on the client, with no server-side authorization or rate limits (EnrichLead case).
The values themselves go into environment variables set in your hosting platform's dashboard or secret store (Vercel, Netlify, Cloudflare, Railway and Supabase all have one), and into a local .env file for development. Do not put them in config files in the repository or in constants in the code.
The repository: .gitignore and history
Add .env and .env.* to .gitignore before the first commit, with an exception line !.env.example, and commit a .env.example with variable names and empty values so the agent and your collaborators know what is expected. If .env is already tracked, git rm --cached .env stops tracking it, but the key is still in every earlier commit. Anyone who clones the repo or a public fork gets the full history.
Secrets that are not in .env are just as dangerous. At PocketOS in April 2026, a coding agent found a Railway CLI token in an unrelated file. The token had been created for domain management but allowed any operation, and the agent used it to delete the production database and its volume backups in 9 seconds. The data was recovered about an hour later with Railway's help (PocketOS case). The agent can read anything in the working directory, so keep infrastructure tokens out of it.
Secret scanning
Use at least one scanner:
- GitHub push protection. For your personal account it is enabled by default and blocks pushes containing recognized secrets to public repositories. Repository-level push protection for private repos requires GitHub Secret Protection and has to be turned on by an admin.
- gitleaks as a pre-commit hook, so a commit with a key fails locally.
- trufflehog, which can also check whether a found credential is still valid.
How to check your app in 15 minutes
-
Search the build output. Run your production build, then search the output folder (
distfor Vite,.next/staticfor Next.js):grep -rnoE '\bsk-[A-Za-z0-9_-]{20,}|sk_live_[A-Za-z0-9]+|sb_secret_[A-Za-z0-9_-]+|service_role|eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+' dist/Any
sk-,sk_live_,sb_secret_orservice_rolehit is a leak. Strings starting witheyJare JWTs, and a legacy Supabase anon key is one of them. Decode the payload locally, without pasting it into a website:node -e "console.log(Buffer.from(process.argv[1].split('.')[1],'base64url').toString())" "PASTE_TOKEN""role":"anon"is expected."role":"service_role"means the admin key is in your frontend. - Watch the Network tab on the live site. Open DevTools and use the main features. A request from the browser directly to
api.openai.com,api.anthropic.comorapi.stripe.comwith anAuthorizationheader means a secret key is in the client. Also check whether.mapfiles load in the Sources tab. -
Scan the repository, including history. Install gitleaks (
brew install gitleaksor a release binary) and run it from the repo root:gitleaks git -v --redact . # every commit, not only the current files gitleaks dir -v --redact dist # the build outputAn alternative that also verifies found keys against the provider:
trufflehog git file://. --results=verified,unknown. -
Confirm .env is ignored and was never tracked.
git check-ignore -v .env # prints the matching .gitignore rule, or nothing git ls-files | grep -i '\.env' # should list only .env.example -
Test the public key as an anonymous user. Take the Supabase key from your bundle and request a table that holds user data:
curl "https://YOUR_PROJECT.supabase.co/rest/v1/profiles?select=*" -H "apikey: YOUR_PUBLISHABLE_KEY"An empty array or a permission error is the expected result. Real rows mean RLS is missing or a policy is too broad.
If a key has leaked
- Rotate the key first. Revoke it in the provider's dashboard and issue a new one, then update the environment variable and redeploy. GitHub's guidance puts this before history cleanup: a revoked key is useless to whoever copied it.
- Check usage and billing. Look at the provider's usage logs and invoices for the period the key was exposed. For a database key, find out what was read or changed.
- Clean the history if the repository is public or shared, with
git filter-repo. Forks, clones and caches may still hold the old commits, which is why rotation comes first. - Fix the cause, so the leak does not return with the next generated feature.
Limit the damage in advance
Plan for a key leaking eventually. Use separate keys for development, staging and production, so a key from a laptop or a preview deployment cannot touch production data. Give each key the narrowest rights the provider allows: Stripe restricted keys with only the permissions one service needs, cloud tokens scoped to one project, read-only where possible. Set monthly spend limits or budget alerts in your OpenAI and Anthropic consoles, and check whether yours is a hard cap or only a notification. Rate-limit every endpoint that calls a paid API. More in the cost section of the checklist.
Checklist
- Only publishable keys reach the browser. No secret has a
NEXT_PUBLIC_,VITE_orEXPO_PUBLIC_prefix. - RLS is enabled on every Supabase table, and an anonymous request with the public key returns no private data.
- Calls to AI APIs, Stripe and admin database operations go through a server endpoint that checks the user and rate-limits requests.
- Secrets live in the hosting platform's environment variables.
.envis in.gitignoreand was never committed. - gitleaks or trufflehog finds nothing in the full git history and the build output.
- No infrastructure tokens sit in the repository or in files the agent can read.
- Keys are separate per environment and scoped to what they need.
- AI providers have spend limits or budget alerts, and you know how to rotate every key in under ten minutes.
A task you can give your agent
Paste this into Cursor, Claude Code or Lovable and review the diff yourself:
Audit this project for secrets exposed to the client.
1. List every environment variable the code reads, with the file and line, and classify each as publishable or secret.
2. For every secret used in client-side code or with aNEXT_PUBLIC_,VITE_orEXPO_PUBLIC_prefix, move the call into a server endpoint that checks the logged-in user and applies a per-user rate limit.
3. Make sure.env*is in.gitignorewith an exception for.env.example, and create.env.examplewith names only.
4. Do not rotate or delete any keys yourself and do not touch production.Acceptance criteria: after
npm run build, searching the build output forsk-,sk_live_,sb_secret_andservice_rolereturns nothing; the browser makes no direct requests toapi.openai.com,api.anthropic.comorapi.stripe.com;gitleaks git -v --redact .reports no leaks, or you list every finding so I can rotate those keys. Show me the diff and the list of keys I need to rotate.
Secrets are the first section of the production checklist for vibe-coded apps. For what happens when these steps are skipped, see 8 real vibe coding security incidents.