Exposed API keys in AI-generated code: how to check and fix
Search your built JavaScript and git history for the first characters of each secret key. If one is there, rotate it first, then move the call into server code, such as a Next.js route handler or a Supabase Edge Function, and drop the NEXT_PUBLIC_ or VITE_ prefix. Deleting the key from the code is not enough.
Symptom
Usually there isn't one, which is the problem. An exposed key works perfectly for you and for whoever else found it. The first sign is often a bill: an AI provider invoice far above your usage, Stripe activity you didn't create, or rows in your database you didn't write.
Sometimes a provider tells you first. GitHub scans repositories for known key formats and, for partner services, reports the leak straight to the provider, which may revoke the key. If your app suddenly stops talking to a service, check your email before you check your code.
There is one case where the app breaks visibly. If an AI tool wired a Supabase secret key into front-end code, Supabase now spots browser requests by their User-Agent header and refuses them:
That block stops the browser using the key. It does not stop anyone who copied it from using it elsewhere, so treat it as a leak.
How to check your own app
Look in three places: the JavaScript your visitors download, your environment variable names, and your git history.
1. The built JavaScript. Open your live site, open the browser developer tools, and search across all loaded sources for the first eight or so characters of each secret key from your dashboards. Or build locally and search the output:
npm run build
# Next.js puts browser code in .next/static, Vite in dist/assets
grep -rlE "sk_live_|sk_test_|rk_live_|sb_secret_|whsec_" .next/static dist/assets 2>/dev/nullLegacy Supabase keys are long JSON Web Tokens starting eyJ, so a prefix search won't find them. If you see one in the bundle, decode it on your own machine (never paste keys into a website) and check the role:
node -e 'const t=process.argv[1]; console.log(JSON.parse(Buffer.from(t.split(".")[1], "base64url")).role)' "PASTE_THE_TOKEN"anon is expected and fine. service_role means your whole database is readable and writable by anyone.
2. Variable names. Anything prefixed NEXT_PUBLIC_ (Next.js) or VITE_ (Vite) ends up in the browser. Look for secrets wearing those prefixes:
grep -rnE "(NEXT_PUBLIC_|VITE_)[A-Z_]*(SECRET|SERVICE_ROLE|PRIVATE|OPENAI|ANTHROPIC)" . --exclude-dir=node_modules --exclude-dir=.next --exclude-dir=dist3. Git history. A key you removed last week is still in older commits:
git log --all --oneline -- .env .env.local .env.production
git log --all -p -S "sk_live_" | head -50The first command shows whether an env file was ever committed. The second searches every commit's changes for a string.
Why it happens
The quickest working demo calls the API from the screen that needs it. Ask for "a button that generates a summary with AI" and the shortest path is a fetch from that component straight to the provider, with the key alongside it. It works first time, so nobody looks again.
The prefix fix. In Next.js, only variables prefixed NEXT_PUBLIC_ reach the browser; in Vite, only VITE_. Both frameworks copy those values into the JavaScript bundle at build time. So when a key is undefined in a browser component, the quickest change that makes the error disappear is adding the prefix. That fixes the error by publishing the key.
No server to put it on. Many AI-built apps are Vite single-page apps with Supabase behind them. There is no server of your own, so a secret has nowhere to live except the browser, until you add an Edge Function.
Env files in the repository. The default Next.js template ignores .env files in git, and Vite's docs recommend ignoring *.local files. Exported or hand-assembled projects don't always keep those rules.
The fix
Rotate the key first
Before touching code, replace the key in the provider's dashboard so the leaked value stops working. Then check the provider's request logs for use you don't recognise.
Move the call to the server
Put the secret in a server-only environment variable and call the provider from a route handler, Server Action or Edge Function. The browser calls your endpoint, never the provider.
Fix the variable names
Remove the NEXT_PUBLIC_ or VITE_ prefix from every secret, and update the code that read it.
Protect the new endpoint
Check the user is signed in and add a rate limit, or you have built a free proxy to your own key.
Clean up git history
Optional once the key is rotated, but worth it if the repository is or will be shared.
Rebuild and check again
Public variables are fixed at build time, so redeploy and repeat the bundle search from the previous section.
1. Rotate
- Stripe: on the API keys page, open the key's menu and choose Rotate key. You pick when the old key expires; for a leaked key, choose Now. Each key has View request logs in the same menu.
- Supabase: create a new secret key, deploy it, then delete the old one. If the leak was a legacy
service_rolekey, follow Supabase's guide to rotating a compromised key, and plan your move to the new key format, since the legacy keys are being deprecated. - AI providers: OpenAI, for example, tells you to rotate a leaked key immediately from its API Keys page. Set a hard monthly spend limit while you are there.
2. Move the call to the server
In Next.js, keep the client in a module marked server-only, which turns any accidental import from a client component into a build error. This is the pattern Stripe's own Next.js sample uses:
// lib/stripe.ts
import "server-only";
import Stripe from "stripe";
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);// app/api/checkout/route.ts
import { NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";
import { createClient } from "@/lib/supabase/server";
export async function POST(request: Request) {
const supabase = await createClient();
const { data } = await supabase.auth.getClaims();
if (!data?.claims) return new Response("Unauthorised", { status: 401 });
const origin = new URL(request.url).origin;
const session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: [{ price: process.env.STRIPE_PRICE_ID!, quantity: 1 }],
client_reference_id: data.claims.sub,
success_url: `${origin}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${origin}/billing`,
});
return NextResponse.redirect(session.url!, 303);
}The price ID lives on the server too. Stripe's docs say to keep prices on the server so customers can't change them.
If your app is a Vite single-page app on Supabase, the server is a Supabase Edge Function. Set the secret with the CLI, then read it with Deno.env.get:
supabase secrets set OPENAI_API_KEY=your-new-key
supabase functions deploy summarise// supabase/functions/summarise/index.ts
import { createClient } from "npm:@supabase/supabase-js@2";
const cors = {
"Access-Control-Allow-Origin": "https://yourapp.com",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
};
Deno.serve(async (req) => {
if (req.method === "OPTIONS") return new Response("ok", { headers: cors });
// Confirm the caller is a signed-in user of your app
const token = (req.headers.get("Authorization") ?? "").replace("Bearer ", "");
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_ANON_KEY")!, // legacy default secret; check your project's key setup
);
const { data: { user } } = await supabase.auth.getUser(token);
if (!user) return new Response("Unauthorised", { status: 401, headers: cors });
const { text } = await req.json();
const res = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
Authorization: `Bearer ${Deno.env.get("OPENAI_API_KEY")}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: Deno.env.get("OPENAI_MODEL"), // set this as a secret too
messages: [{ role: "user", content: `Summarise:\n\n${text}` }],
}),
});
return new Response(await res.text(), {
status: res.status,
headers: { ...cors, "Content-Type": "application/json" },
});
});The browser then calls supabase.functions.invoke("summarise", { body: { text } }), and the provider key never leaves Supabase.
3. Fix the variable names
| Key | Looks like | In the browser? |
|---|---|---|
| Supabase publishable | sb_publishable_... | Yes, with RLS on every table |
| Supabase legacy anon | eyJ..., role anon | Yes, with RLS on every table |
| Supabase secret | sb_secret_... | Never |
| Supabase legacy service_role | eyJ..., role service_role | Never |
| Stripe publishable | pk_live_..., pk_test_... | Yes |
| Stripe secret or restricted | sk_..., rk_... | Never |
| Stripe webhook signing secret | whsec_... | Never |
| AI model, email or SMS provider keys | varies | Never |
4. Protect the new endpoint
A server route that calls a paid API for anyone who asks is the same leak with an extra step. Both examples above reject callers who are not signed in. Add a per-user rate limit backed by a shared store, as Next.js's own guidance suggests, and keep the spend cap on the provider side.
5. Clean up git history
Rotation is what makes you safe. Rewriting history is tidying up, and GitHub's own guide says to rotate first. If you do it, use git-filter-repo 2.47 or later:
git-filter-repo --sensitive-data-removal --invert-paths --path .env
git push --force --mirror originThis changes every commit hash, and copies in forks and other people's clones are untouched, so tell collaborators to re-clone.
How to prevent it
- Mark server modules. Put
import "server-only"at the top of any file that reads a secret. Next.js recommends that only your data access layer touchesprocess.env. - Use narrower keys. Stripe recommends restricted keys with only the permissions each part of your app needs, and access policies that limit where a key can be used from. Supabase recommends a separate secret key per backend component, so one leak means one rotation.
- Turn on secret scanning. GitHub scans the whole history of public repositories for free; private repositories in organisations need GitHub Secret Protection.
- Keep env files out of git. Check
.gitignorecovers.env*and*.localbefore your first commit, not after. - Cap the damage. Set spend limits on every paid API, so a leak costs a capped amount rather than whatever someone can run up.
- Tell your AI tool the rule. Add a line to the project's instructions file: secrets are server-only, never prefixed, never in components. Then check the bundle after big changes anyway.
When to get help
Rotating a key and moving one call to the server is a job most founders can do with this guide. Get help if:
- The leaked key was a Supabase secret or service_role key on a live app. Anyone could have read or changed every row. You need to know what was accessed, and if personal data was likely exposed you may have to report it: the ICO's rule is within 72 hours of becoming aware, if the breach is likely to put people at risk.
- Several keys are tangled into front-end code and moving them means restructuring how the app fetches data.
- You have no server at all and need to decide between Edge Functions and moving to a framework with server code.
This is a standard part of a Rescue Sprint: I find every exposed key, rotate them with you, move the calls server-side and leave notes so the next prompt doesn't put them back. For the full list of holes to check alongside this one, see 10 security holes in vibe-coded apps, or start with the guide to fixing vibe-coded apps. If you are not sure where you stand, run the free health check first.
- Supabase docs: API keys, checked 9 November 2026
- Supabase docs: Edge Function secrets, checked 9 November 2026
- Next.js docs: Environment variables, checked 9 November 2026
- Next.js docs: Data security, checked 9 November 2026
- Vite docs: Env variables and modes, checked 9 November 2026
- Stripe docs: API keys, checked 9 November 2026
- Stripe docs: Stripe-hosted checkout quickstart, checked 9 November 2026
- OpenAI Help Center: Best practices for API key safety, checked 9 November 2026
- GitHub docs: About secret scanning, checked 9 November 2026
- GitHub docs: Removing sensitive data from a repository, checked 9 November 2026
- ICO: Personal data breaches, a guide, checked 9 November 2026
The vibe-code rescue checklist, as a PDF
About 40 checks across auth, data, payments, errors, mobile and secrets, on two pages.
Questions founders ask
Is it safe to have my Supabase key in front-end code?
The publishable key (sb_publishable_) or legacy anon key is designed to be public, as long as every table has Row Level Security. The secret key (sb_secret_) or legacy service_role key must never reach the browser, because it bypasses Row Level Security.
I deleted the key from my code. Am I safe now?
No. The key is still in your git history, in any deployed build that included it, and possibly in someone's copy. Rotate it in the provider's dashboard so the old value stops working.
Can I hide a key by obfuscating it or splitting it into parts?
No. Anything the browser can use, a visitor can read. The only fix is to keep the key on a server and have the browser call your server instead.

Ant
Product designer who builds MVPs, based in Nottingham. 10+ years designing digital products, and the only person at BlackFig. More about me