How to check whether your Stripe, OpenAI or Supabase secret key is exposed in your front end
Check your live site, not your code. Search the JavaScript the browser downloads, and any source maps, for secret key prefixes such as sk_live_, sk-proj- and sb_secret_. Decode any Supabase key to see whether its role is service_role, and watch the network tab for requests that go straight from the browser to OpenAI or Stripe. Any secret you find there is already public. Create a replacement key, move the call behind a server function, revoke the old key, then check the provider's logs for use you did not cause.
Drafted with AI assistance. Published before our human review step began, and not yet reviewed by a person.
How do I check my live site for an exposed secret key?
Check the deployed site in a browser, because the bundle a stranger downloads is the only version that matters, and it can differ from the code you think you shipped.
Your repository can look clean while the deployed bundle is not. A key can be inlined at build time from an environment variable, left in an older build that is still being served, or sitting in a source map that your host publishes next to the JavaScript. So start from the outside, the way a stranger would.
Check 1: search what the browser downloaded
Open your production URL in a private window with developer tools open. In Chrome, open the Sources panel and press Cmd+Option+F on a Mac or Ctrl+Shift+F on Windows to search every loaded file at once. Search for each prefix from the next section, one at a time. Sign in and click through the main screens first, because code for logged-in pages may only load after you sign in, so a search of the homepage alone can miss it.
If you prefer the terminal, list the scripts your page loads and search each one:
# list the script files your homepage loads
curl -s https://yourapp.com/ | grep -oE 'src="[^"]+[.]js"'
# fetch one and search it for secret key prefixes
curl -s https://yourapp.com/assets/index-abc123.js \
| grep -oE 'sk_live_[A-Za-z0-9]+|rk_live_[A-Za-z0-9]+|sk-proj-[A-Za-z0-9_-]+|sk-ant-[A-Za-z0-9_-]+|sb_secret_[A-Za-z0-9_-]+'Check 2: look for source maps
Look at the last line of each JavaScript file for a comment starting with //# sourceMappingURL=. If it points to a .map file and that file loads in your browser, your original source code is public, including any key someone typed into it. Source maps are useful for debugging, but they belong in your error tracker, not on your public site.
Check 3: watch the network tab
Open the Network panel, use the app normally, and look at where requests go. A request from the browser straight to api.openai.com, api.anthropic.com or api.stripe.com carrying an Authorization: Bearer header means the key is in the page, whatever the bundle search found. The browser should only talk to your own server, or to services built for direct browser use with a publishable key, such as Supabase or Stripe Checkout.
Which keys are safe in the browser, and which are not?
Publishable keys are designed to be public, secret keys never are, and the prefix on a key usually tells you which one you are looking at.
Here is how to read what you found:
- Stripe.
pk_live_andpk_test_keys are publishable and meant for the browser.sk_live_andsk_test_are secret keys.rk_live_is a restricted key: still secret, just with fewer permissions. A live secret key can create charges and refunds and read your customer records. - OpenAI. Keys start with
sk-, and project keys start withsk-proj-. There is no public OpenAI key. Anyone holding one can run requests billed to your account. - Anthropic. Keys start with
sk-ant-. Same rule: none of them belong in a browser. - Supabase, newer keys.
sb_publishable_keys are meant for the browser.sb_secret_keys bypass row level security and must stay on a server. - Supabase, legacy keys. The anon key and the service role key are both long tokens starting with
eyJ, so they look identical at a glance. The next section shows how to tell them apart. - Everything else. Database connection strings with a password in them, Stripe webhook signing secrets (they start with
whsec_), and keys for email or SMS providers are all server-only.
The Stripe and Supabase dashboards label which key is which. When in doubt, the rule is simple: if the provider's documentation says to keep a key secret, it does not go in any file a browser can download.
How do I tell a Supabase anon key from a service role key?
Decode the middle of the key and read its role: anon is the public key your app is supposed to ship, and service_role is the key that ignores every row level security policy.
A legacy Supabase key is a JSON Web Token: three sections separated by dots, and the middle one is readable JSON. Decode it locally with Node, which sends nothing anywhere:
node -e 'console.log(Buffer.from(process.argv[1].split(".")[1], "base64url").toString())' 'PASTE_THE_KEY_HERE'You will see a short JSON object. The field that matters is role:
{ "iss": "supabase", "ref": "yourproject", "role": "anon" }If the role is anon, the key is public by design and finding it in your bundle is expected. Your protection then comes from row level security, a separate question covered in the Supabase RLS post.
If the role is service_role, rotate it today. That key bypasses every row level security policy, so whoever holds it can read and change every table, whatever your policies say. The free Supabase exposure check refuses a service_role key in your browser for exactly this reason.
Do not paste a key into an online token decoder to find out what it is. If it turns out to be a live secret, you have just handed it to a third party.
Why do AI coding tools put secret keys in the front end?
Because the quickest way to make a feature work in a demo is to call the API straight from the page, and a prompt that says make it work rarely says keep the key on a server.
Three habits put keys there, and each one is easy to recognise in your own code.
- The environment variable prefix. Vite only exposes variables that start with
VITE_to client code. Next.js does the same forNEXT_PUBLIC_, Create React App forREACT_APP_, and Expo forEXPO_PUBLIC_. The prefix is an instruction to copy the value into the JavaScript bundle at build time. When an assistant hits an undefined variable in the browser, renamingOPENAI_API_KEYtoVITE_OPENAI_API_KEYmakes the error go away and publishes the key. - The direct client call. The OpenAI and Anthropic JavaScript SDKs both refuse to run in a browser unless you pass an option named
dangerouslyAllowBrowser. An assistant asked to make that error disappear can add the option in one line. If you find it in your code, you have found your leak. - The pasted key. A key pasted into a prompt, a chat or a config file tends to end up hardcoded in source. If that source lives in a public repository or a public project on a hosted builder, the key is exposed even if it never reached the bundle. Lovable's own published incident response states that between 3 February and 20 April 2026, the chat histories and source code of public Lovable projects could be read by any signed-in Lovable user. If you pasted a key into a public project's chat in that window, treat it as exposed.
The scanned populations show this is a real, findable failure. Symbiotic Security's scan of 1,072 Supabase-backed vibe-coded apps found 24 with an API key in source code, and Escape's passive scan of 5,600 publicly reachable vibe-coded apps counted more than 400 exposed secrets. Those are counts within each study, not rates you can apply to every app, and both were found from the outside, by looking.
How do I rotate an exposed key without taking my app down?
Create the new key first, switch your server to it, deploy, confirm it works, and only then revoke the old one, because revoking first breaks every feature that still depends on it.
The order matters more than the speed. A rotation done backwards takes production down at the moment you are already under pressure.
- Create the replacement key in the provider's dashboard. Stripe lets you roll a secret key and choose how long the old one keeps working. OpenAI and Anthropic let you create a new key and revoke the old one separately.
- Store the new key as a server-only variable in your host's environment settings, with no
VITE_,NEXT_PUBLIC_or similar prefix. - Move every call that used the key behind a server route (next section), and remove the key from client code entirely.
- Deploy and test every feature the key powers: checkout, AI responses, emails.
- Revoke the old key. Then load the live site again and repeat the bundle search to confirm the new build carries no secret.
- Check what the old key was used for. Read the provider's logs and usage pages for activity you did not cause: unexpected charges or refunds in Stripe, a usage spike on your OpenAI or Anthropic account, new keys or webhook endpoints you did not create.
Supabase is the special case. If your project uses the legacy JWT-based keys, the service role key cannot be revoked on its own. Replacing it means rotating the project's JWT secret, which also replaces the anon key and invalidates existing sessions, so users may have to sign in again. Plan it as a short maintenance window and ship the new anon key to your front end at the same time. Projects using the newer sb_secret_ keys can create a new secret key and revoke the old one independently. Your project's API Keys settings page shows which kind you have.
One more trap: if the key was ever committed to a public Git repository, deleting it in a new commit does not help. It is still in the history. Revoking it is the step that closes the leak.
What does moving the call to a server actually look like?
The browser calls an endpoint you own, that endpoint checks who is asking, and only the server holds the secret and talks to the third-party API.
Here is the shape as a Supabase Edge Function, which suits an app whose backend is already Supabase:
// supabase/functions/ask/index.ts
import { createClient } from "npm:@supabase/supabase-js@2";
Deno.serve(async (req) => {
// 1. Who is asking? Verify the caller's own access token, refuse anyone else.
const token = (req.headers.get("Authorization") ?? "").replace("Bearer ", "");
const supabase = createClient(Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_ANON_KEY")!);
const { data: { user } } = await supabase.auth.getUser(token);
if (!user) return new Response("Unauthorized", { status: 401 });
// 2. Only the server holds the secret.
const { prompt } = await req.json();
const upstream = 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: "YOUR_MODEL",
messages: [{ role: "user", content: String(prompt).slice(0, 4000) }],
}),
});
return new Response(await upstream.text(), {
status: upstream.status,
headers: { "Content-Type": "application/json" },
});
});Set the key with supabase secrets set OPENAI_API_KEY=... so it never touches your front-end code, and call the function from the page with supabase.functions.invoke("ask", { body: { prompt } }), which sends the signed-in user's access token for you. The function checks that token with getUser(token); a request carrying only the public anon key gets no user back and is refused. The same shape works as a Next.js route handler or a Vercel function: read the secret from a server-only environment variable, check the user, then call the API.
The user check is not optional. A server route with no authentication is an open relay: the key is hidden, but anyone can call your endpoint and spend your credit through it. Add a per-user cap on how often it can be called, and cap the size of what it accepts, as the example does with the prompt.
When is this a do-it-yourself job, and when is it not?
If you found one key, the app has no paying users, and the provider's logs show nothing unusual, you can rotate it and move the call server side yourself this afternoon.
That is a real answer, not a hedge. Work through the rotation steps, repeat the three checks against your live site, and you are done.
Get help when one of these is true:
- A Supabase service role key or a database connection string was public. That is full read and write access to your data, so the questions become what was read or changed, for how long, and what you owe your users. Export your logs before anything else, while they still cover the exposure window.
- The provider's logs show use you did not cause. Unexpected Stripe activity or an AI usage spike means someone found the key before you did. The fix is the same, but the clean-up and the conversation with the provider are not.
- You cannot list every place the key is used. If rotating it might break features you cannot name, map the dependencies first, or the rotation becomes an outage.
- The leak came from a habit, not one mistake. If one key escaped through a
VITE_variable, others may have too, and the same scans that count leaked keys also count tables readable by anyone.
The Rapid Diagnostic is $1,500: an automated scan plus a written report, delivered async in days, and credited toward the full audit if you go ahead. If you want the whole app assessed rather than just its keys, that is the AI app security audit.
Where the numbers come from
Every figure above comes from published third-party research, compared side by side with its method and sample on our State of Vibe-Code Security page. We have no dataset of our own.
- Symbiotic Security, 2 June 2026. An automated external scan of 1,072 Supabase-backed apps built on Lovable, v0, Bolt.new, Replit and Windsurf. Findings are detections, not confirmed exploitation, and the apps were found by crawling, not sampled at random.
- Escape, 29 October 2025. A passive scan of 5,600 publicly reachable apps built with vibe-coding tools. Its figures are counts of findings, not rates, and the authors call them a conservative baseline.
- Lovable's own published incident response, 22 April 2026. The vendor's own account of one platform access-control incident affecting public projects. It carries no frequency.
Related reading.
The rescue playbook.
Real prompts, security rules, and teardowns of broken AI-built apps, sent when we publish something worth your inbox. No fluff, no drip sequence.
One or two emails a month. Unsubscribe anytime.
Want every key, route and table checked, not just the ones you found?
We assess the whole app from the outside and the inside, then hand you a prioritized findings list with the fix for each item.
Common questions about exposed secret keys.
Does deleting the key from my code fix the leak?
No. Deleting it stops new builds from shipping it, but anyone who already copied it can keep using it, an older build may still be served from a cache, and the key stays in your Git history. The leak is closed only when the old key is revoked in the provider's dashboard. Delete it from the code, rotate it, then confirm the live bundle and any public repository no longer hold a working secret.
Are the environment variables in my hosting dashboard always private?
Only if your front-end code never reads them during the build. Your host stores the value privately, but if the variable's name starts with a client prefix such as VITE_ or NEXT_PUBLIC_, the build copies the value into the JavaScript every visitor downloads. Keep secrets in variables without those prefixes, and read them only from server code such as API routes, edge functions or serverless functions.
Can I hide a key in the front end by obfuscating or encrypting it?
No. Anything the browser can use, a person with developer tools can read, because the browser has to decode the key to send the request. Obfuscation only slows down someone who is already looking. The fix is to keep the key on a server and have the browser call your own endpoint, which checks who is asking before it uses the key.
How do I know whether someone already used my leaked key?
Check each provider's own records for the period the key was public. Stripe's dashboard keeps logs of recent API requests, OpenAI and Anthropic show usage over time in their consoles, and Supabase keeps API and database logs for a window that depends on your plan. Look for activity you cannot match to your own app, and export the logs before they age out.