API key exposed in the frontend bundle
These tools will call OpenAI, Stripe, AWS, or GitHub from the same component that draws the button. The bundler then copies the key into a file the browser downloads. View source is enough to take it. Deleting the line in the next chat does not erase the copy already on the deployed site. Rotate the key, then rebuild.
What you see
- A script under /assets or /_next/static, or the HTML itself, contains sk-proj-, sk-live-, sk-test-, sk-ant-, sk_live_, sk_test_, rk_live_, AKIA, or ghp_. The page still loads. The key is sitting in the file.
- The network tab shows the browser calling api.openai.com, api.stripe.com, or api.github.com with Authorization: Bearer and a secret. No request goes to your own server first.
- The feature works while you are logged out. Nothing on a server checks who is spending the key.
- The env name starts with VITE_, NEXT_PUBLIC_, or PUBLIC_. The host's env screen looks correct. The built file still contains the value, because those prefixes are copied in at build time.
Why this happens
- The prompt said to call the API, and the generated code put fetch in the component, with the key in a const or a public env var.
- A .env value was given a public prefix so the preview would boot, and that prefix was still there at deploy.
- The key was pasted into a Lovable, Bolt, or Cursor chat, and the generated file kept the paste.
Common causes
The provider call lives in a client component
If the function that calls OpenAI, Stripe, or GitHub runs in the browser, the key it uses is in the browser too. A file with "use client", a Vite page, a Replit frontend script, and a Bolt component are all browser code. A comment that says the file is server-only does not change where it runs.
How to check. Search the repo for api.openai.com, api.stripe.com, and api.github.com. A match in a component that renders JSX, or in a file that component imports, is on its way into the bundle.
A public env prefix inlined the secret
Vite replaces import.meta.env.VITE_* when it builds. Next.js replaces process.env.NEXT_PUBLIC_* the same way. The string in the built JavaScript is the value, not the variable name. Restarting the old deployment does not remove a value that was inlined. The site has to be built again after the name changes.
How to check. Open the deployed script and search for the key prefix. Then find that same value on the env screen under a VITE_ or NEXT_PUBLIC_ name. A server-only name has neither prefix.
The chat paste was written into source
Pasting sk-proj- or sk_live_ into a generator chat is a common way a const ends up in a source file. The preview works because the const is right there. The deploy publishes it.
How to check. Search the source, not only the env screen, for sk-proj-, sk_live_, AKIA, and ghp_. A hit in a file the client imports is the leak, even when the env screen is clean.
A production source map republishes the key
A .map file next to the script can contain the original line. Some hosts serve that map to anyone who requests it. Minified JavaScript that hides the key can still point at a map that contains it.
How to check. If the script ends with a sourceMappingURL, open that URL. Search the map for the same prefix. A map that contains the key is another public copy. Remove the map from the public host, or rebuild without publishing it.
Fix it
1. Search the deployed site
The repo can be clean while the last build is not. Run this in the browser console on the deployed origin. It reads the HTML and the same-origin scripts. Top-level await works in the console. It prints the file URL and the first characters of each hit. A full key in your console is still a secret. Rotate it.
const html = await fetch(location.href).then((response) => response.text());
const urls = [location.href];
for (const match of html.matchAll(/<script[^>]+src=["']([^"']+)["']/gi)) {
const url = new URL(match[1], location.href);
if (url.origin === location.origin) urls.push(url.toString());
}
const pattern = /sk_live_[A-Za-z0-9]+|sk_test_[A-Za-z0-9]+|rk_live_[A-Za-z0-9]+|sk-(?:proj|live|test)-[A-Za-z0-9_-]{16,}|sk-ant-[A-Za-z0-9_-]+|AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{20,}/g;
for (const url of urls) {
const body = url === location.href ? html : await fetch(url).then((response) => response.text());
pattern.lastIndex = 0;
for (const hit of body.matchAll(pattern)) console.log(url, hit[0].slice(0, 12));
}2. Sort publishable keys from secrets
Stripe pk_ keys are publishable. The Supabase anon key is meant to be in the browser. Keep those. Stripe sk_ and rk_ keys, OpenAI keys, Anthropic keys, AWS access key ids, and GitHub personal access tokens are secrets. A Supabase JWT whose role is service_role is a secret too. That case has its own page, linked below. Older OpenAI keys are a bare sk- plus a long random string, with no proj, live, or test in the prefix. Search for those by hand. The pattern above looks for the prefixed forms.
3. Call your server from the component
The button can stay in the client. The provider call cannot. From the browser, call a route on your origin, or a Supabase function, and send the user's text. The key stays on the server. Reject signed-out callers when the feature is not public. An open route spends the key for anyone who finds the URL, which is the next bug.
// Supabase client, when the app already uses Supabase.
const { data, error } = await supabase.functions.invoke("complete", {
body: { prompt },
});
// Same-origin Next.js route. Use one of these, not both.
await fetch("/api/complete", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ prompt }),
});4. Put the provider call where the key is not bundled
Lovable and Bolt apps that already use Supabase can put the call in an Edge Function. Set the secret with supabase secrets set, which stores it for the function, not for the client. Leave verify_jwt on so the function requires the user's Supabase JWT. That is the opposite of a Stripe webhook function, which has to turn JWT verification off because Stripe does not send a Supabase user JWT. A Cursor or Replit app on Next.js can use a route handler instead. The route file has no "use client". The env name has no NEXT_PUBLIC_ prefix. Use whichever server your app actually deploys.
// supabase/functions/complete/index.ts
// supabase secrets set OPENAI_API_KEY=...
// Leave verify_jwt on. It is the default.
Deno.serve(async (request) => {
const { prompt } = await request.json();
const response = 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: "gpt-4o-mini",
messages: [{ role: "user", content: String(prompt).slice(0, 4000) }],
}),
});
return new Response(response.body, {
status: response.status,
headers: { "content-type": "application/json" },
});
});
// app/api/complete/route.ts
// No VITE_ or NEXT_PUBLIC_ prefix. No "use client".
export async function POST(request: Request) {
const { prompt } = await request.json();
const response = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
authorization: "Bearer " + process.env.OPENAI_API_KEY,
"content-type": "application/json",
},
body: JSON.stringify({
model: "gpt-4o-mini",
messages: [{ role: "user", content: String(prompt).slice(0, 4000) }],
}),
});
return Response.json(await response.json());
}5. Rotate the key, then rebuild
Removing the key from the next source file does not un-copy the old one. In the provider's dashboard, revoke or roll the key that appeared in the bundle. OpenAI, Stripe, GitHub, and AWS each have a revoke or roll action on that key. Put the new value only in the server environment. Redeploy the function or the server. Rebuild the frontend so the client bundle is produced again. If a CDN is still serving the old asset name, wait until the HTML points at the new files, or purge that cache. Search the new HTML, scripts, and any source map for the old prefix.
How to verify
- Open the deployed URL in a private window. Run the search snippet again. The old key is absent. A Stripe pk_ key or a Supabase anon key may remain. That is expected.
- In the network tab, the browser calls your origin or your Supabase function. It does not call api.openai.com with a bearer secret.
- The feature still works for a signed-in user. A signed-out request to the route is rejected when the feature is private.
- If a source map is still linked from the script, that map does not contain the key either.
How the daily check catches it
Once a day, and any time you run the free scan, BearWatch downloads the public page and its same-origin scripts and runs those patterns. A hit fails the secret scan and names the key type, with a redacted sample. The scan does not log in and does not call your API.
Related guides
- Stripe secret key shipped in the JavaScript. sk_live_ in the bundle, and what to roll in the Stripe dashboard.
- Supabase service_role key in the frontend. How to tell the public anon JWT from a service_role JWT. The service_role key bypasses row level security.
- Lovable Stripe webhook not working. A Supabase function that receives Stripe has to turn JWT verification off. A function your users call, such as one that holds an OpenAI key, should leave it on.
What BearWatch looks for
- The free scan and the daily check fetch the public HTML and up to eight same-origin scripts. They do not run the JavaScript. A key built at runtime from fragments can be missed. The search step above is how you catch that.
- They flag Stripe live and test secret keys, Stripe live restricted keys, OpenAI keys that start with sk-proj-, sk-live-, or sk-test-, AWS access key ids that start with AKIA, GitHub tokens that start with ghp_, and a JWT whose role is service_role.
- They do not flag a Stripe pk_ key or a Supabase anon key. An Anthropic key (sk-ant-) or a bare sk- OpenAI key has a different shape. Search for those yourself.
A public look, before the daily check
The free scan and the free roast both read the public HTML and same-origin scripts on the URL you deployed. The scan names the key type and a redacted sample. The roast includes the same kind of finding in the grade and the fix prompt. Neither one logs in. A key with a shape the patterns do not cover can still be in the file, so run the search snippet too.
Prompt to paste
A secret API key is in the public JavaScript. Remove provider calls from client components. Read OpenAI, Stripe secret, AWS, and GitHub tokens only from server environment variables with no VITE_ or NEXT_PUBLIC_ prefix. On Supabase, put the call in an Edge Function, set the secret with supabase secrets set, and leave verify_jwt on. Rotate every key that was in the bundle. Rebuild and confirm the deployed scripts no longer contain the old value. Keep Stripe pk_ keys and the Supabase anon key in the browser.