How to Stop Your AI App From Leaking API Keys and Running Up Your Bill: A 7-Step Beginner Guide
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

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.
grep -rnE "sk-|sk_live|sk_test|sb_secret_|service_role|dangerouslyAllowBrowser|VITE_.*KEY|NEXT_PUBLIC_.*KEY" \ --exclude-dir = node_modules --exclude-dir =.git. Enter fullscreen mode Exit fullscreen mode What each match means:
If nothing shows up and requests go to your own domain, skip to Step 5.
Do not start with your code. Start at the provider.
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.
Not every key is secret. Some are designed for the browser, and mixing them up is how many leaks happen.
Now the trap. Build tools copy some environment variables into the code they ship to browsers:
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.
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 }); } Enter fullscreen mode Exit fullscreen mode 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 (); Enter fullscreen mode Exit fullscreen mode 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:
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 }); } Enter fullscreen mode Exit fullscreen mode 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.
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 }); Enter fullscreen mode Exit fullscreen mode 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.
Also use a separate key per project, and OpenAI recommends keys with an expiration date plus regular rotation.
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. Enter fullscreen mode Exit fullscreen mode 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 Enter fullscreen mode Exit fullscreen mode 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.
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.
Enter fullscreen mode Exit fullscreen mode 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:
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.