Your AI feature works and the demo looks great. One question before you share the link: where does your AI API key live?
If you built your app with Cursor, Claude Code, Lovable, Bolt, Replit or v0, there is a real chance the answer is "in the browser". That means anyone who opens your site can copy the key and spend your credits. It is one of the most common mistakes posted in r/vibecoding, usually right after a surprise bill.
This guide shows you how to check, clean up, and make sure it cannot happen again. No security background needed.
Three quick terms first:
- API key: a password your app sends to a service (OpenAI, Anthropic, Stripe) to prove it is allowed to use it. Whoever has the key can spend your money.
- Frontend: the code that runs in your visitor's browser. Anyone can read it.
- Backend (or server): code that runs on a machine you control, like a Next.js route, a Supabase Edge Function or a Cloudflare Worker. Visitors only see what it sends back.
Step 1: Check if your key is already exposed
- Open your live app in Chrome and open DevTools (F12, or right-click and choose Inspect).
- Go to the Network tab and use your AI feature once.
- Look at the request it makes. If it goes straight to
api.openai.comorapi.anthropic.com, your key is in the browser. If it goes to your own domain (like/api/chat), that is a good sign. - Press Ctrl+Shift+F (Cmd+Option+F on Mac) to search across all loaded files. Search for
sk-and the name of your key, likeOPENAI.
Then search your project folder too:
grep -rnE "sk-|sk_live|sk_test|sb_secret_|service_role|dangerouslyAllowBrowser|VITE_.*KEY|NEXT_PUBLIC_.*KEY" \
--exclude-dir=node_modules --exclude-dir=.git .
What each match means:
-
sk-...orsk_live...written directly in a file: a secret key is hardcoded. Fix it today. -
dangerouslyAllowBrowser: the official OpenAI and Anthropic JavaScript libraries refuse to run in a browser unless you set this flag, because it exposes your secret key. If your AI tool set it to make an error go away, that is your leak. -
VITE_OPENAI_API_KEYorNEXT_PUBLIC_OPENAI_API_KEY: see Step 3. The prefix itself is the problem.
If nothing shows up and requests go to your own domain, skip to Step 5.
Step 2: If it leaked, revoke the key first
Do not start with your code. Start at the provider.
- Go to your provider's API keys page (OpenAI, Anthropic, Stripe and so on).
- Create a new key.
- Revoke (delete) the old one.
- Check the usage page for spending you do not recognize.
Why first? Removing the key from your code does not remove it from copies people already downloaded or from your git history. GitHub's own guide on removing sensitive data says the first step for a leaked password or token is to revoke or rotate it, and that once it is revoked, that may be enough. Rewriting git history is risky and often unnecessary.
Two things worth knowing:
- GitHub runs secret scanning on public repositories for free. For keys from partner providers, GitHub notifies the provider, which may revoke the key. OpenAI joined that program in 2021. Do not count on it: private repos are not covered the same way, and bots can use a key before anyone revokes it.
- Pasting a key into your coding agent's chat is a common way it ends up in source code. Lovable detects keys pasted into chat and points you to its Secrets feature. With other tools, add keys yourself in the right place (Step 4).
Step 3: Learn which keys are safe to be public
Not every key is secret. Some are designed for the browser, and mixing them up is how many leaks happen.
| Key | Safe in the browser? | Why |
|---|---|---|
| Supabase anon or publishable key | Yes, if Row Level Security is on | Supabase says so in its docs. RLS is what protects your data. |
Stripe publishable key (pk_...) |
Yes | Stripe says it can't create charges or read account data. |
| Google Maps browser key | Yes, if restricted to your domain | Lovable's docs call this out as the exception. |
| OpenAI, Anthropic or other AI keys | No | Anyone can spend your credits. |
Stripe secret or restricted key (sk_..., rk_...) |
No | A secret key has unrestricted access to all Stripe APIs. |
| Supabase service_role or secret key | No | Bypasses Row Level Security, so it can read every row. |
Now the trap. Build tools copy some environment variables into the code they ship to browsers:
-
Vite (used by Lovable, Bolt and many React apps) puts any variable starting with
VITE_into your client code. Vite's docs say plainly thatVITE_*variables should not contain sensitive information such as API keys. -
Next.js (common with v0 and Cursor) inlines any variable starting with
NEXT_PUBLIC_into the JavaScript sent to the browser.
So .env does not mean "hidden". VITE_OPENAI_API_KEY is public once you build. Rename it without the prefix and read it only on the server.
Step 4: Move the AI call to your backend
The shape you want is simple:
Browser -> your backend (holds the key) -> AI provider
Here is a minimal Next.js route handler. It reads the key on the server only, and the browser never sees it.
// app/api/chat/route.ts
import OpenAI from "openai";
const client = new OpenAI(); // reads OPENAI_API_KEY from the server environment
export async function POST(req: Request) {
const { message } = await req.json();
const response = await client.responses.create({
model: process.env.AI_MODEL!, // set on the server, never sent by the browser
input: message,
});
return Response.json({ reply: response.output_text });
}
Your frontend now calls your own route, with no key anywhere:
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: userText }),
});
const { reply } = await res.json();
Set OPENAI_API_KEY (no prefix) in your hosting dashboard's environment variables, and in .env.local for local work. Make sure .env* files are in .gitignore.
Not on Next.js? Same idea, different place:
- Lovable: store the key in Secrets. Lovable's docs say Secrets are encrypted, injected into Edge Functions or server functions, and never reach the browser.
-
Supabase (Bolt, Cursor or Claude Code builds): put the call in an Edge Function and set the key with
supabase secrets set OPENAI_API_KEY=.... The function reads it withDeno.env.get("OPENAI_API_KEY"). - Plain HTML or a mobile app: you still need a small backend, such as a Cloudflare Worker or a Supabase Edge Function.
Step 5: Lock the endpoint so strangers cannot use it
If /api/chat accepts requests from anyone, a stranger can skip your site and call it directly. They never see your key, but they still spend your money.
Add three checks at the top of your route:
export async function POST(req: Request) {
// 1. Only signed-in users. Use your auth library's server-side check.
const user = await getSignedInUser(req);
if (!user) return new Response("Please sign in", { status: 401 });
// 2. Only reasonable input
const { message } = await req.json();
if (typeof message !== "string" || message.length > 2000) {
return new Response("Message too long", { status: 400 });
}
// 3. The server decides the model and the output size, not the browser
const response = await client.responses.create({
model: process.env.AI_MODEL!,
input: message,
max_output_tokens: 500,
});
return Response.json({ reply: response.output_text });
}
getSignedInUser is a placeholder. Replace it with the server-side check from your auth provider (Supabase, Clerk, Firebase, Auth.js and so on). With Supabase Edge Functions, keep JWT verification on for user-facing functions. Supabase's docs warn that turning it off to clear a 401 removes the platform's check.
The third check matters: if the browser can send model or max_tokens, someone can swap in your most expensive model.
Step 6: Add a rate limit and a spending cap
Logged-in users can still abuse a free plan, and one buggy loop can send thousands of requests. You want two safety nets.
A per-user rate limit in your app. A rate limit means "each user gets at most N requests per time window". Upstash Ratelimit is a small library built for serverless setups like Vercel, Cloudflare Workers and Deno. After creating an Upstash Redis database and adding its two environment variables, the code is short:
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(20, "1 h"), // 20 AI requests per user per hour
});
// inside POST, right after the sign-in check:
const { success } = await ratelimit.limit(user.id);
if (!success) return new Response("Too many requests, try again later", { status: 429 });
20 per hour is an example; pick numbers that fit your product. Limit by user ID rather than IP address, since many real users can share one IP.
A spending cap at the provider. This is your last line of defense if everything else fails.
- OpenAI: set a monthly spend limit for your project and turn on hard enforcement. OpenAI's help center notes that spend alerts alone do not stop traffic, and spend may slightly exceed the limit because enforcement is not instant.
- Anthropic: set your own spend limit on the Billing page in the Claude Console. Once reached, requests return an error until you raise the limit or the period resets.
Also use a separate key per project, and OpenAI recommends keys with an expiration date plus regular rotation.
Step 7: Stop it happening again
A few habits keep this fixed.
Add rules for your AI tool. Put this in your project rules file (AGENTS.md, .cursor/rules, CLAUDE.md, or Lovable's knowledge settings):
Security rules for this project:
- Never put secret API keys in frontend code, HTML, or any variable starting
with VITE_ or NEXT_PUBLIC_.
- All calls to AI providers, Stripe, or email services go through server code
(API routes, server functions, or Edge Functions) that reads keys from the
server environment.
- Never set dangerouslyAllowBrowser.
- Every endpoint that calls a paid API must check the signed-in user, validate
input length, set the model and max output tokens on the server, and apply a
per-user rate limit.
- If a task needs a new secret, stop and ask me to add it. Never write a key
into a file.
Scan before you push. Gitleaks is a free, open-source scanner that finds secrets in files and git history. On a Mac with Homebrew:
brew install gitleaks
gitleaks git -v . # scans your git history
gitleaks dir -v . # scans the files on disk
It can also run as a pre-commit hook on every commit.
Use your platform's checks. GitHub push protection for users is on by default and stops you from pushing supported secrets to public repositories. Lovable runs a quick security scan on every publish and offers a deeper scan that looks for leaked secrets and endpoints anyone can trigger. Helpful, but not a replacement for checking yourself.
Repeat the Step 1 check after every deploy. It takes a minute.
And once your keys are safe, it is worth checking the rest of the live site too: security headers, HTTPS redirects, what search engines see. I build Siteory, a free audit that checks a live site for security, SEO and AI-search issues and gives you a developer-ready fix list, if you want a second pair of eyes.
Copy-paste audit prompt
Paste this into Cursor, Claude Code, Lovable or Bolt to have your AI tool check its own work:
Audit this project for exposed secrets and abusable paid API calls.
1. List every place an API key, token, or secret appears in code, config,
or env files, and say whether that file is shipped to the browser.
2. Flag any variable starting with VITE_ or NEXT_PUBLIC_ that holds a secret.
3. Flag any direct browser call to OpenAI, Anthropic, Stripe, or other paid
APIs, and any use of dangerouslyAllowBrowser.
4. For every server endpoint that calls a paid API, check: does it require a
signed-in user, limit input size, set model and max tokens on the server,
and apply a per-user rate limit?
Show me the list first. Do not change code until I approve. Never print the
full value of any secret you find.
Common mistakes to avoid
- Deleting the key from code but not revoking it. The old key still works for whoever copied it.
- Moving the key to the server but leaving the endpoint open. Strangers can still spend your money through your own route.
- Relying on spend alerts. Alerts tell you; hard limits stop the spending.
- Pasting keys into the AI chat. Add them through your platform's secrets screen instead.
Action summary
Do this today, in this order:
- Open DevTools on your live site, use your AI feature, and check where the request goes.
- Search your project for
sk-,VITE_,NEXT_PUBLIC_anddangerouslyAllowBrowser. - If any secret was exposed, create a new key, revoke the old one, and check usage.
- Move every paid API call to a server route, Edge Function or Lovable Secret.
- Require sign-in, cap input size, and set the model and max output tokens on the server.
- Add a per-user rate limit and a hard monthly spend limit at your provider.
- Add the rules to your AI tool, install Gitleaks, and repeat the DevTools check after every deploy.
Your AI key is a credit card number with no PIN. Keep it on the server, put limits around it, and check it every time you ship.
Top comments (0)