Secrets Management for Lovable Apps
AI-built apps frequently bake API keys directly into the JavaScript bundle. Right-click any Lovable app, View Source, search for "sk_". A surprising number leak Stripe, OpenAI, or Supabase service-role keys to every visitor. Here's how to find, fix, and prevent leaks.
- Which keys are safe to expose, and which are secret
- The specific failure pattern in Lovable apps
- How to check your app for leaked secrets in 60 seconds
- How to fix a leak the right way
- How to move secret-using code to the server
- Common pitfalls when fixing leaks
- Related secrets-hygiene issues to check
Which keys are safe to expose, and which are secret
Not every API key is a secret. Most providers ship two kinds of keys: publishable keys (safe to expose to the client) and secret keys (must stay on the server).
Publishable keys are designed to be visible. They have limited permissions: typically things like "create a payment session that returns to a redirect URL" or "load this Stripe checkout component." They can't move money. They can't read sensitive data. They're meant to be embedded in HTML and JavaScript.
Secret keys, on the other hand, can do anything the account can do. Charge cards, refund payments, query private databases, call expensive APIs. They must never leave the server.
Here's the prefix cheat sheet for the most common providers:
- Stripe.
pk_live_*/pk_test_*are publishable (safe).sk_live_*/sk_test_*are secret (server only).rk_*are restricted keys (server only). - OpenAI. All API keys start with
sk-and are secret. There is no publishable equivalent. If your OpenAI key is in the client, it is leaked. - Anthropic. Keys start with
sk-ant-and are all secret. Same rule as OpenAI. - Supabase. The
anonkey is intentionally public (safe to embed) but requires RLS to be enforced. Theservice_rolekey bypasses RLS and is secret. JWT secrets are also secret. - Other providers. If the documentation calls something a "secret key," "private key," or "API key with full access," treat it as secret. When in doubt, treat it as secret.
The specific failure pattern in Lovable apps
AI builders are very good at writing functional code. They will write whatever produces a working app from the user's prompt, including dropping API keys directly into client-side files because that's the path of least resistance.
Three patterns I see most often:
Pattern 1: Direct hardcoding
// Don't do this. Visible in the bundle.
const openai = new OpenAI({
apiKey: 'sk-proj-aBcDeFgHiJkLmNoPqRsTuVwXyZ123456789',
});
Sometimes the user pasted the key into the chat with the AI, and the AI used it inline. Sometimes the AI generated a placeholder and the user replaced it with their real key. Either way, the key ends up as a string literal in the source.
Pattern 2: Wrong environment variable prefix
// Visible in the bundle, because the prefix exposes it.
const stripeKey = process.env.NEXT_PUBLIC_STRIPE_SECRET_KEY;
In Next.js, any environment variable prefixed with NEXT_PUBLIC_ is intentionally embedded in the client bundle at build time. In Vite, it's VITE_. In Create React App, it's REACT_APP_. AI builders sometimes use these prefixes incorrectly because they "make the variable work" without understanding that they expose the variable to the client.
If your secret key is in an environment variable named NEXT_PUBLIC_STRIPE_SECRET_KEY or VITE_OPENAI_API_KEY, the prefix is leaking it. Drop the prefix and move the usage to a server-side function.
Pattern 3: Service role keys in client code
This one is the worst. The AI generates Supabase code that uses the service_role key (which bypasses RLS entirely) and ships it to the client to "make the data load." The result is essentially admin access to your database in every visitor's browser.
// Catastrophic. service_role bypasses RLS.
const supabase = createClient(
SUPABASE_URL,
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' // service_role key in JS bundle
);
If this is in your app, your entire database is publicly readable AND writable. Rotate the key immediately and rebuild with the anon key + proper RLS policies.
How to check your app for leaked secrets in 60 seconds
Three checks. Run all of them.
Check 1: View Source on the deployed app
Open your deployed app in a normal browser tab. Right-click anywhere, choose "View Page Source" (not Inspect Element). The raw HTML and JS that ships to every visitor loads in a new tab. Hit Cmd+F (Mac) or Ctrl+F (Windows) and search for:
sk_(Stripe secret keys, OpenAI keys with this prefix)sk-(OpenAI, Anthropic)rk_(Stripe restricted)service_role(Supabase admin)- your provider's specific prefix if it's none of the above
If any of these turn up actual key values (not just the prefix appearing in a variable name), you have a leak.
Check 2: Search the JavaScript bundle files
View Source shows the initial HTML, but most app code lives in separate JS bundle files. Click into the bundle files (typically in /_next/static/, /assets/, or similar paths) and search them the same way. The keys are easier to find here because they live as string literals in the minified code.
A faster method: download the bundle locally and grep.
# Replace yourapp.com with your actual domain
curl -s https://yourapp.com/ | grep -oE 'src="[^"]*\.js"'
# Then for each .js file:
curl -s https://yourapp.com/path/to/bundle.js | grep -oE 'sk[_-][A-Za-z0-9_]{20,}'
Check 3: Use a public scanner
Tools like TruffleHog and Gitleaks are designed to find leaked secrets in code. They can be pointed at a deployed URL or a local checkout. For Lovable apps specifically, a 5-second manual View Source + Cmd+F usually catches what matters, but automated scans add a safety net.
How to fix a leak the right way
If you found a leaked key, follow these steps in order. Order matters.
Step 1: Rotate the leaked key first, before anything else
The leaked key is compromised the moment it ships to a public URL. Even if you remove it from the code today, anyone who saved it has it forever. Go to the provider dashboard and rotate the key:
- Stripe. Dashboard → Developers → API keys → "Roll" next to the leaked key
- OpenAI. Platform → API keys → revoke the leaked key and create a new one
- Anthropic. Console → API keys → revoke and replace
- Supabase. Project settings → API → "Reset" next to the leaked key (note: rotating the anon key requires updating it in your code; rotating service_role doesn't, since it shouldn't be in client code at all)
Step 2: Check provider dashboards for unauthorized usage
Look at usage and charges over the past 30 days. Anything unexpected? An OpenAI key leaked for a week could have racked up four-figure charges. Stripe could have had unauthorized refund or payout attempts. Most providers' usage dashboards will show this.
If you find suspicious activity, contact the provider's support immediately. Most have a process for emergency revocations and credit reimbursements for clear abuse cases.
Step 3: Find every place the leaked key was used in your code
Search your entire codebase (not just the bundle) for the key value. Anywhere it appears, replace it with a reference to a properly-configured environment variable.
Step 4: Move the secret-using code to the server
The actual fix isn't just "use an environment variable." It's "the code that uses the secret shouldn't run in the browser at all." Even with proper env var handling, if the code path that uses the key is in your client bundle, the key gets compiled in.
This is the harder part of the fix. The next section walks through how.
How to move secret-using code to the server
The pattern: take the code that uses the secret, wrap it in a server-side function, and have the client call your function instead of calling the provider directly.
Before (client-side, leaks the key)
// In a React component, client-side
import OpenAI from 'openai';
const openai = new OpenAI({
apiKey: process.env.NEXT_PUBLIC_OPENAI_KEY, // LEAKED
});
async function generateText(prompt) {
const response = await openai.chat.completions.create({
model: 'gpt-4',
messages: [{ role: 'user', content: prompt }],
});
return response.choices[0].message.content;
}
After (server-side function, client calls it)
// /api/generate.js (server-side)
import OpenAI from 'openai';
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY, // NOT prefixed = server only
});
export default async function handler(req, res) {
const { prompt } = req.body;
// Validate, rate limit, auth check before calling OpenAI
if (!prompt || prompt.length > 2000) {
return res.status(400).json({ error: 'Invalid prompt' });
}
const response = await openai.chat.completions.create({
model: 'gpt-4',
messages: [{ role: 'user', content: prompt }],
max_tokens: 1000,
});
res.status(200).json({ result: response.choices[0].message.content });
}
// In your React component (client-side)
async function generateText(prompt) {
const response = await fetch('/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt }),
});
const data = await response.json();
return data.result;
}
Three things changed:
- The OpenAI client now lives in a server-side file (
/api/generate.js), which never gets shipped to the browser. - The environment variable is
OPENAI_API_KEY(noNEXT_PUBLIC_prefix), so it's only available server-side. - The client makes a request to your own endpoint, which acts as a proxy to OpenAI. The actual key never appears in client code.
This is also where you add rate limiting, input validation, and auth checks. The server-side function becomes your single point of control for everything.
Common pitfalls when fixing leaks
Pitfall 1: Removing the key from code but not rotating it
If you find a leaked key and just delete it from the code, the key is still compromised. Anyone who saw it before still has it. Rotate first, then update the code with the new key.
Pitfall 2: Using NEXT_PUBLIC_ or VITE_ prefixes for secrets
These prefixes mean "embed this value in the client bundle." That's the opposite of what you want for secrets. Drop the prefix and ensure the code that reads the variable runs on the server, not in the browser.
Pitfall 3: Storing keys in .env files committed to git
If your .env file is committed to a public GitHub repo, the keys are public regardless of whether they're in the deployed bundle. Always include .env and .env.* in your .gitignore. If you've already committed a .env file, the keys in it are compromised even after you remove and rewrite the git history. Rotate them.
Pitfall 4: Using the same key everywhere
If your one OpenAI key is used by 10 different services, rotating it because of one leak forces you to update 10 places. Provider-side restricted keys (Stripe has these, Supabase has them, OpenAI has project-scoped keys) let you create per-service or per-app keys with limited scopes. Use them, especially for high-stakes integrations.
Pitfall 5: Logging keys to the console
A surprising number of bugs end with someone adding console.log(process.env.OPENAI_API_KEY) while debugging, then forgetting to remove it. Browser console logs are visible to anyone who opens DevTools. Audit your code for any logging of environment variables.
If you've ever pasted an API key into a chat conversation with Lovable, Claude, ChatGPT, or any other AI tool, assume the key is compromised and rotate it. Chat history can be logged, reviewed by humans, used in training, or leaked. Treat any key that's been shared in a chat the same as a key you found in a public bundle.
Related secrets-hygiene issues to check
Once the leaked keys are rotated and moved server-side, three more places to check.
Test mode keys in production
Stripe and Supabase both have separate test/dev keys (prefixed sk_test_, pk_test_) and production keys (sk_live_, pk_live_). If your production deployment has test keys configured, real charges won't clear. Audit your production environment variables and confirm they use live keys.
Webhook signing secrets
Stripe webhooks have their own signing secret (prefixed whsec_) that's separate from your API key. If you're verifying webhook signatures, make sure that secret is in env vars too, and not hardcoded.
Database connection strings
The Postgres connection string for your Supabase database (with the password in it) is also a secret. It belongs in env vars, never in client code, and never in a committed .env file.
The TL;DR
- Most providers have publishable keys (safe in client) and secret keys (server only). Know which is which.
- OpenAI, Anthropic, Stripe
sk_, and Supabaseservice_roleare all secret. They never belong in the browser. - Check: View Source on your app, search the bundle for
sk_,sk-,rk_,service_role. - Fix in order: (1) rotate the leaked key, (2) check provider for unauthorized usage, (3) update code, (4) move secret-using code to a server-side function.
- Avoid
NEXT_PUBLIC_,VITE_,REACT_APP_prefixes for anything secret. - If you ever pasted a key in any AI chat, assume it's compromised and rotate it.
Even after the service_role key is removed, your app still needs RLS policies on every table or anyone can read the data. The other half of Supabase security.
Once your secret keys are server-side, the next layer is rate limiting the endpoints that use them. Both layers together prevent the worst cost-and-data failure modes.
The pillar piece explaining the meta-pattern: why AI-built apps consistently ship with the same five production gaps, and what to do about it.
Want this done for you?
If you'd rather not spend a weekend grepping bundles, rotating keys, and refactoring every secret-using call site, Rivetz audits and hardens Lovable apps for production. The audit ($1,000, 3 business days) finds every issue. The cleanup ($3,500, 14 business days) implements every CRITICAL and HIGH severity fix including secrets, RLS, rate limits, and webhook security.
100% async. No calls. No scope creep. Fixed price.
See offers See full checklist