OpenAI or Stripe API Key Exposed in Frontend Code? Why It Happens and How to Fix It
You asked Lovable, Bolt, Cursor, Claude, Grok or ChatGPT to "add AI chat" or "add Stripe payments". It worked first time. Then someone in a Discord channel pointed out that your API key is sitting in your site's JavaScript where anyone can read it.
This is one of the most common mistakes in vibe-coded apps, and it isn't really a bug. It's how frontend build tools are designed to work. Here's what's going on and how to fix it properly.
Why your key ends up in the browser
Frontend frameworks have a convention for marking environment variables as safe to make public:
- Vite (used by Lovable, most Bolt output, plain React templates): variables starting with
VITE_ - Next.js: variables starting with
NEXT_PUBLIC_ - Astro / SvelteKit: variables starting with
PUBLIC_
At build time, the value of any variable with one of these prefixes is copied as plain text into the JavaScript your visitors download. So this:
const openai = new OpenAI({
apiKey: import.meta.env.VITE_OPENAI_API_KEY,
dangerouslyAllowBrowser: true,
});
ships your real key to every visitor. (The OpenAI SDK even makes you set dangerouslyAllowBrowser to do it. That name is a warning.)
AI assistants often reach for this pattern because the browser code needs the key, the build fails without the prefix, and adding VITE_ makes the error go away. Nothing breaks, so you don't notice.
OpenAI's own guidance is clear: exposing your key in a browser or mobile app lets other people make requests on your behalf, which can mean unexpected charges, and requests should be routed through your own backend.
What a leaked key costs you
- OpenAI / Anthropic / other AI keys: anyone can spend your credit. Developers regularly report surprise bills after shipping a key in an app. Spend limits help, but treat them as a backstop. They don't stop abuse instantly.
- Stripe secret key (
sk_live_…): much worse. A secret key has unrestricted access to your account's API: it can create charges, issue refunds and read customer details. (Restricted keys,rk_…, are limited to the permissions you give them.) It must never be in frontend code. - Stripe publishable key (
pk_live_…): this one is meant to be public. Seeing it in your bundle is normal.
How to check if you're exposed
- Search your code for public-prefixed secrets:
grep -rnE "(VITE|NEXT_PUBLIC|PUBLIC)_[A-Z_]*(OPENAI|ANTHROPIC|STRIPE_SECRET|SECRET|SERVICE_ROLE)" src/ .env* 2>/dev/null - Search for raw key formats:
sk-(OpenAI-style),sk_live_andrk_live_(Stripe secret and restricted keys). - Check what you actually shipped: open your live site, go to DevTools → Sources (or Network), and search the loaded JavaScript for
sk-orsk_live. - Check your git history, not just today's files. A key you removed last week is still in old commits.
Step 1: Revoke and replace the key
Do this before you change any code. The key has already been served to browsers and may have been scraped, and deleting it from your source doesn't change that.
OpenAI: go to the API keys page in the OpenAI platform, delete the exposed key, and create a new one. Check the Usage page for activity you don't recognise, and review your organisation's members.
Stripe: on the API keys page open the key's ⋯ menu and choose Rotate key. Stripe lets the old key keep working for up to 7 days so you can update your servers, but for a key that is public, set the expiration to Now. Put the new key in your server environment, then review recent payments, refunds, payouts and any newly created API keys or webhooks. Stripe now recommends restricted keys (rk_live_…) with only the permissions your server needs instead of the all-powerful secret key.
Step 2: Move the call to a server
The rule: any key that costs money or grants access lives only on a server. Your frontend calls your endpoint, and your endpoint calls OpenAI or Stripe.
In a Lovable or Supabase-based app, a Supabase Edge Function works well:
// supabase/functions/chat/index.ts — runs on the server
import OpenAI from "npm:openai";
Deno.serve(async (req) => {
// 1. Check the user is logged in (verify their Supabase JWT) before spending money
const { messages } = await req.json();
const openai = new OpenAI({ apiKey: Deno.env.get("OPENAI_API_KEY") }); // no VITE_ prefix
const result = await openai.chat.completions.create({
model: "gpt-4o-mini",
messages,
});
return Response.json(result);
});
Set OPENAI_API_KEY as a function secret, then call the function from the browser with supabase.functions.invoke("chat", { body: { messages } }).
In Next.js, use a route handler such as app/api/chat/route.ts and read process.env.OPENAI_API_KEY. Without the NEXT_PUBLIC_ prefix, it stays on the server.
For Stripe, the browser should only ever:
- load Stripe.js with the publishable key, and
- send the user to a Checkout Session (or confirm a PaymentIntent) that your server created with the secret key.
Prices and amounts should be decided on the server, never taken from the browser. Otherwise a user can edit the request and pay $0.01.
Step 3: Don't just move the leak
A server endpoint with no protection is a leaked key with extra steps. Anyone can call it and spend your credit. Add:
- Authentication: only logged-in users can call it.
- Rate limits: per user and per IP.
- Input limits: cap message length and token use, and pick the model on the server.
- Spend alerts at the provider, plus a hard monthly budget where one is offered.
- Restricted keys for Stripe where possible, so a key only has the permissions it needs.
Paste-ready fix prompt
"Find every API key used in frontend code. Remove any secret (OpenAI, Anthropic, Stripe secret key, Supabase service role) from the browser and from any VITE_ or NEXT_PUBLIC_ variable. Create a server-side endpoint (Supabase Edge Function or API route) that reads the key from a server-only environment variable, requires an authenticated user, and applies rate limiting. Update the frontend to call that endpoint. Keep only the Stripe publishable key and Supabase anon key in the browser."
After the AI makes the change, check the diff yourself and search the built bundle again.
Find exposed keys before someone else does
Run a free ShipCheck scan on your GitHub repo (public, or private via Connect GitHub). ShipCheck uses gitleaks to find OpenAI, Stripe and other keys in your current code and your last 100 commits, flags .env and .gitignore mistakes, and checks your dependencies for known vulnerabilities. You get a plain-English 0–100 score and a list of what to fix first.
Built it with Lovable, Bolt, Cursor, Claude, Grok or ChatGPT? Is your repo safe to ship?
Paste a public GitHub repo, or connect GitHub for a private one, and get a risk score in under a minute. Free.
Built it with Lovable, Bolt, Cursor, Claude, Grok or ChatGPT? Is your repo safe to ship?
Paste a public GitHub repo, or connect GitHub for a private one, and get a risk score in under a minute. Free.