Skip to content
LockShip

Guide · Secrets · Updated

Leaked API keys in your frontend bundle: how to find them and what to do

Anything your app’s browser code needs, every visitor’s browser gets, and anyone can open a browser. If a secret key ends up in the JavaScript your site sends out, it isn’t hidden behind a login or buried in minified code. It’s published. This is how we check our own apps for that: which keys are meant to be public, which never are, how they slip into a build without anyone typing them there, and what to do in the first hour if you find one. Every command on this page was run against a deliberately leaky test app we built for the purpose, with fake keys, before we published it.

Not every key in your bundle is a leak

The first time you search your own site and find a long, random-looking key, your stomach drops. Sometimes it should. Often it shouldn’t, because several services hand you a key that is designed to sit in a web page. The question to ask is whether the key decides what someone is allowed to do, or only says which project they’re talking to.

Public by design. These are fine in your JavaScript, as long as the thing behind them is locked properly:

  • The Supabase publishable key (sb_publishable_), or the older anon key. Supabase’s API keys guide says it is safe to expose online and only reaches what Row Level Security allows. The lock is RLS, and our Supabase RLS checklist covers it.
  • The Firebase web config apiKey, the one starting AIza. Firebase’s API keys page calls these keys “public by design”: they identify your project, and Security Rules and App Check do the protecting. Our Firebase Security Rules checklist covers those.
  • The Stripe publishable key (pk_live_ or pk_test_). Stripe’s API keys docs say it can identify your account and create tokens or PaymentMethods, but can’t create charges or read account data, and that you can put it in front-end code.

Never in the browser. If any of these is in your bundle, treat it as stolen:

  • The Supabase secret key (sb_secret_) or the older service_role key. It bypasses every Row Level Security policy you have. Supabase now refuses secret keys sent from a browser, which helps, but its docs are clear that someone who copies a leaked one can still use it from other tools.
  • Stripe secret keys (sk_live_, sk_test_), restricted keys (rk_live_, rk_test_), and webhook signing secrets (whsec_). Stripe’s key table marks restricted keys as not safe to expose either. Its best practices page says whoever gets your secret key can make charges, access customer data, or disrupt your integration. The webhook secret is how your server knows an event really came from Stripe, so whoever holds it can fake one.
  • AI keys. OpenAI’s key safety page puts it bluntly: never deploy your key in client-side environments like browsers or mobile apps, because anyone can take it and make requests on your behalf. Anthropic keys start with sk-ant-, and Anthropic’s best practices compare them to a credit card number. A Gemini Developer API key is the one Google key Firebase says must never be in your code.
  • Everything else that unlocks something on its own: GitHub tokens, AWS access keys, database connection strings with a password in them, and Google service account files.

When you meet a key you don’t recognise, look in the provider’s docs for the words publishable, public, or safe to expose. If you can’t find them, treat it as a secret.

How secrets end up in the bundle

Nobody decides to publish their Stripe key. It happens in a few very ordinary ways, and the first one is by far the most common in apps an AI helped write.

The prefix. Build tools keep your environment variables on the server unless you mark them as public, and the mark is a prefix. Vite’s env docs say variables starting with VITE_ are exposed in client-side code after bundling, and that they “should not contain sensitive information such as API keys”, because their values are bundled into your source code at build time. Next.js does the same with NEXT_PUBLIC_: its environment variables guide says the value is inlined into any JavaScript sent to the browser.

Here is how it goes wrong. You ask your builder to add an AI chat. It writes a component that calls OpenAI straight from the page. In the browser the key comes out undefined, because unprefixed variables don’t exist there. The suggested fix, often from the same AI, is to rename the variable to VITE_OPENAI_API_KEY. The error goes away, the chat works, and your key is now in a file every visitor downloads. We did exactly that in our test app, and the key showed up in plain text in the built JavaScript.

Pasting it in. You paste a key into the chat so the AI can “just wire it up”, and it lands as a string in the code. Stripe’s best practices note that bad actors continuously scan public repositories for Stripe keys, and a key hardcoded in a component ends up in your repo and your bundle at the same time.

Source maps. A source map lets the browser show your original code instead of the minified version. Both Vite and Next.js leave them off for production builds by default. If someone turned them on, your original files, comments and all, can be downloaded next to the bundle, along with any key you once typed into them. Step 6 covers this.

The frozen copy. Public variables are copied into the files when you build, and the Next.js docs point out that the built app no longer responds to changes in them. Deleting the variable in your hosting dashboard doesn’t touch the files already deployed. A new build does, but the old key was public for as long as it was live, which is why step 8 always ends in a rotation.

The checklist

Steps 1 to 7 find the problem. Steps 8 to 10 fix it and keep it fixed. If you only have ten minutes, do 1, 2 and 3. If you can run a build on your own machine, do 4 and 5 as well, because they catch what the others miss.

  1. Write down every key you’ve handed the app
  2. Search the live site in DevTools
  3. Decode every eyJ key and read its role
  4. Search your build output, not just the page
  5. Check every script your live site serves
  6. Look for public source maps
  7. Search your git history
  8. If you find one: stop the leak, then rotate
  9. Move the call to your server
  10. Put guardrails in place

01

Write down every key you’ve handed the app

Before you search for anything, make the list. Open wherever your app’s environment variables live: your Vercel project settings, the Secrets panel in Lovable, Bolt or Replit, or the .env file in your project. For each variable write down two things: which service it belongs to, and whether its name starts with VITE_ or NEXT_PUBLIC_. Then add any key you remember pasting into an AI chat.

Now compare it with the two lists above. Any key from the “never” list with a public prefix is already in your bundle. You don’t need a search to know that. Here’s what a healthy split looks like for a Vite app:

.env · public and private, kept apart
# Public by design. Fine to ship to the browser.
VITE_SUPABASE_URL=https://your-project.supabase.co
VITE_SUPABASE_PUBLISHABLE_KEY=sb_publishable_...
VITE_STRIPE_PUBLISHABLE_KEY=pk_live_...

# Secrets. No VITE_ (or NEXT_PUBLIC_) prefix, ever.
SUPABASE_SECRET_KEY=sb_secret_...
STRIPE_SECRET_KEY=sk_live_...
OPENAI_API_KEY=sk-...

This list is also the honest limit of every automated check, ours included. A scanner only knows the key formats someone taught it. You know which services you actually signed up for.

02

Search the live site in DevTools

Open your live site in Chrome and open DevTools. Press Ctrl+Shift+F (Cmd+Option+F on a Mac) to open the Search panel, which searches across all the resources the page has loaded. Turn on the regular-expression toggle (the button that looks like .*) and paste this:

DevTools search · regular expression
sk_live_|sk_test_|rk_live_|whsec_|sb_secret_|sk-[\w-]{20,}|service_role|eyJ[\w-]+\.eyJ[\w-]+

That covers the Stripe secret, test, restricted and webhook prefixes, Supabase secret keys, anything shaped like an OpenAI or Anthropic key, the literal word service_role, and JWTs, the long keys starting eyJ that step 3 deals with. Click a result and read the code around it. A library can contain sk- followed by something harmless, and a Stripe key name in an error message is not the key itself.

One catch we only noticed while testing. The panel searches what the page has loaded so far, and modern builds split your code into pieces that load when they’re needed. In our test app, the OpenAI key and a service_role key sat in the code for an admin screen that only downloads after a click. Until that click the file isn’t loaded, so there is nothing for the panel to search: only the Stripe key and the public anon key in the main file show up. So sign in, click through every screen, open the admin area if you have one, and search again. Or rely on steps 4 and 5, which don’t depend on where you clicked.

03

Decode every eyJ key and read its role

A key that starts with eyJ is a JWT, a small signed document. The middle part is just base64-encoded text, so the word service_role never appears in plain text in your bundle. In our test app, the service_role key sailed straight past every word search and was only caught once we decoded it. Copy each eyJ key you found and paste this into the Console of your own browser:

Browser Console
// Paste into the Console of your own browser. The key never leaves your machine.
const key = "PASTE_THE_KEY_HERE";
JSON.parse(atob(key.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"))).role;
// "anon" is the public key. "service_role" is the one that must never ship.

Please do this in your own console rather than pasting the key into a website that decodes JWTs. If it turns out to be the secret one, you don’t want it in a stranger’s server logs. Supabase’s newer keys aren’t JWTs at all, so if a tutorial or an AI tells you to copy a long key that begins with eyJ, the Supabase docs say it was written for the legacy keys.

If the role is service_role, go straight to step 8. If it’s anon, that key is fine, and the thing to check next is whether your tables have RLS. Our CVE-2025-48757 explainer shows what happens when they don’t.

04

Search your build output, not just the page

This is the most thorough check, because it reads every file your build produced, including the ones that only load on some screens. Run a production build on your machine (usually npm run build), then run these from your project folder. They look in dist for Vite apps and .next/static for Next.js, which are the parts that get sent to browsers. On Windows, use Git Bash or WSL.

Terminal · search the build for secret-looking strings
# Run from your project folder after a production build.
# Looks in Vite's dist/ and Next.js's .next/static/ (the parts browsers download).
PATTERN='sk_live_|sk_test_|rk_live_|rk_test_|whsec_|sb_secret_|sk-[A-Za-z0-9_-]{20,}|service_role|AKIA[0-9A-Z]{16}|ghp_|github_pat_|xox[baprs]-|BEGIN [A-Z ]*PRIVATE KEY|postgres(ql)?://[^:/"]+:[^@"]+@'

# 1. Which files contain something that looks like a secret?
grep -rElI "$PATTERN" dist .next/static 2>/dev/null

# 2. What kinds? (prints only the first 14 characters of each match)
grep -rEohI "$PATTERN" dist .next/static 2>/dev/null | cut -c1-14 | sort | uniq -c

The first command lists the files with a match. The second counts what kinds of thing it found and prints only the first 14 characters of each, so the full key doesn’t end up in your terminal history. Then the JWTs:

Terminal · read the role of every JWT in the build
# Lists every JWT-shaped string in the build and the role inside it.
grep -rEohI 'eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+' dist .next/static 2>/dev/null | sort -u |
  node -e '
    const lines = require("fs").readFileSync(0, "utf8").split("\n").filter(Boolean);
    for (const t of lines) {
      try {
        const p = JSON.parse(Buffer.from(t.split(".")[1], "base64url").toString());
        console.log(p.role ?? "(no role claim)", p.ref ?? "", t.slice(0, 10) + "…");
      } catch { console.log("(not a readable JWT)", t.slice(0, 10) + "…"); }
    }'

On our leaky Vite test app, the first script named both JavaScript files and found an sk_live_ key and an sk-proj- key. The second found one anon JWT and one service_role JWT. On a small Next.js test app, a key we had named NEXT_PUBLIC_OPENAI_API_KEY turned up in a file under .next/static/chunks/app/, while a STRIPE_SECRET_KEY read only in server code wasn’t anywhere in the .next folder at all. That is the prefix rule from above, seen from the inside.

05

Check every script your live site serves

If your app lives in Lovable, Bolt or Replit and you never run a build yourself, you can still check what the world actually downloads. This small script fetches your page, downloads every script on your domain that the page loads, follows the file names those scripts mention (which is how lazily loaded screens get pulled in), and runs the same checks as step 4. It needs Node.js 18 or newer. Save it as check-live.mjs, and only point it at sites you own.

JavaScript · check-live.mjs
// check-live.mjs: downloads the scripts your live site sends to browsers
// and lists anything that looks like a secret key.
// Usage: node check-live.mjs https://your-app.com
const site = new URL(process.argv[2]);
const SECRET = /sk_live_|sk_test_|rk_live_|rk_test_|whsec_|sb_secret_|sk-[\w-]{20,}|AKIA[0-9A-Z]{16}|ghp_|github_pat_|xox[baprs]-|BEGIN [A-Z ]*PRIVATE KEY|postgres(?:ql)?:\/\/[^:\/"]+:[^@"]+@/g;
const JWT = /eyJ[\w-]{10,}\.eyJ[\w-]{10,}\.[\w-]+/g;

const queue = [];
const seen = new Set();
const html = await (await fetch(site)).text();
for (const m of html.matchAll(/<script[^>]+src=["']([^"']+)["']/g)) queue.push(new URL(m[1], site).href);

while (queue.length && seen.size < 300) {
  const url = queue.shift();
  if (seen.has(url) || new URL(url).host !== site.host) continue;
  seen.add(url);
  const res = await fetch(url);
  if (!res.ok) continue;
  const js = await res.text();
  // Follow chunks the script loads later (lazy pages, admin screens).
  for (const m of js.matchAll(/["'`]((?:\.{0,2}\/)?[\w\/.-]+\.js)["'`]/g)) queue.push(new URL(m[1], url).href);
  for (const m of js.matchAll(SECRET)) console.log(`${m[0].slice(0, 12)}…  in ${url}`);
  for (const m of js.matchAll(JWT)) {
    let role = "unreadable";
    try { role = JSON.parse(Buffer.from(m[0].split(".")[1], "base64url")).role ?? "none"; } catch {}
    console.log(`JWT, role "${role}"  in ${url}`);
  }
}
console.log(`Checked ${seen.size} script files on ${site.host}.`);
Terminal · run it
node check-live.mjs https://your-app.com
node check-live.mjs https://your-app.com/dashboard

On our test app it found all four planted keys: the Stripe secret key and the anon JWT in the main file, and the OpenAI key and the service_role JWT in the admin screen’s file that a DevTools search had missed. We also ran it against lockship.dev. It checked 15 script files and found nothing, which is the answer we wanted. Run it on your sign-in page and dashboard as well as the home page, since some frameworks only list a page’s scripts on that page.

06

Look for public source maps

Source maps are files ending in .map that rebuild your original code from the minified bundle. In Vite, build.sourcemap is off by default. Setting it to true writes a separate map next to each file. Setting it to "hidden" only drops the comment that points to the map. The file is still in dist, so if you deploy that folder, it can still be downloaded. Next.js leaves browser source maps off in production builds, and its docs say that is “to prevent you leaking your source on the client”, unless you turn on productionBrowserSourceMaps.

Terminal · list source maps in your build
find dist .next/static -name "*.map" 2>/dev/null

If that prints anything and you didn’t choose to publish your source, turn the setting off and rebuild. Then search the old maps with the commands from step 4. Maps carry your original code, so a key you hardcoded can sit in a map in readable form.

07

Search your git history

Removing a key in a later commit doesn’t remove it from the commits before. If your project syncs to GitHub, which Lovable, Bolt and most builders offer, the old version is one click away for anyone who can see the repo. This lists every commit, on any branch, that added or removed a line that looks like a secret:

Terminal · inside your project’s git folder
# Lists every commit, on any branch, that added or removed a line that looks like a secret.
PATTERN='sk_live_|sk_test_|rk_live_|rk_test_|whsec_|sb_secret_|sk-[A-Za-z0-9_-]{20,}|service_role|AKIA[0-9A-Z]{16}|ghp_|github_pat_|xox[baprs]-|BEGIN [A-Z ]*PRIVATE KEY|postgres(ql)?://[^:/"]+:[^@"]+@'
git log --all --extended-regexp -G"$PATTERN" --format='%h %ad %s' --date=short

In our test repo it listed both the commit that added a Stripe key and the later one that moved it to an environment variable, which is exactly the point: the key is still in history even though today’s code is clean.

GitHub does some of this for you. Secret scanning checks the entire Git history on all branches, and runs automatically for free on public repositories. Its list of supported patterns includes OpenAI, Anthropic, Stripe and Supabase secret keys among hundreds of others. Anthropic, for one, says it automatically deactivates a key that turns up in a public repository and emails you about it.

08

If you find one: stop the leak, then rotate

Order matters here. First change the code so the key isn’t in the browser any more, using the pattern in step 9, and deploy that. If you rotate first, your next deploy may ship the new key exactly the way it shipped the old one. Supabase’s leaked-key steps open with the same advice: fix the root cause before you rotate anything.

Then rotate straight away, even if you can’t tell whether anyone saw it. Stripe says to treat any exposure as a potential compromise. If the fix will take more than a few minutes, revoke the key now and accept that the feature is broken until you’re done. We would rather have checkout down for twenty minutes than a live secret key on the internet for twenty minutes.

  • Stripe: on the API keys page, open the menu next to the key and choose Rotate key. Rotation normally keeps the old key working for up to 7 days so you can switch over gracefully. For a leaked key, choose Now as the expiration, which deletes the old key. Then open the key’s request logs in Workbench and look for calls you didn’t make.
  • OpenAI: rotate the key from the API Keys page. OpenAI’s API reference says revocations take effect within a few seconds. Review the Usage page for anything you don’t recognise.
  • Anthropic: in the Claude Console, go to API keys, open the menu next to the key and choose Delete API Key. New keys start with sk-ant- and, per Anthropic’s key docs, are shown in full only once, so put the new one straight into your server’s secrets.
  • Supabase: create a new secret key under Settings, API Keys, replace the old one everywhere, confirm everything works, then delete the old one. Deleting can’t be undone, so do it last. For a legacy service_role key, the docs say to deactivate the legacy keys instead. That covers the anon key your app may still use too, so move the browser to a publishable key first.
  • Anything else: revoke the GitHub token, deactivate the AWS key pair, change the database password. If the provider has a usage or audit log, read it for the time the key was live.

Once the key is rotated, rewriting git history is optional. GitHub’s guide to removing sensitive data says as much, and the questions below explain why.

09

Move the call to your server

Every key on the “never” list gets the same fix. The browser asks your server, and your server, which holds the key, asks the service. In a Next.js app that is a route handler. The key is read from an environment variable with no public prefix:

JavaScript · app/api/ask/route.js
// app/api/ask/route.js runs on the server. The key is read here and never sent to the browser.
export async function POST(request) {
  // Before spending your credit, check who is asking (signed-in user, rate limit).
  const { question } = await request.json();

  const upstream = await fetch("https://api.openai.com/v1/responses", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.OPENAI_API_KEY}`, // no NEXT_PUBLIC_ prefix
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ model: process.env.OPENAI_MODEL, input: String(question).slice(0, 2000) }),
  });

  if (!upstream.ok) return Response.json({ error: "AI request failed" }, { status: 502 });
  const data = await upstream.json();
  return Response.json({ answer: data.output_text ?? data.output });
}
JavaScript · the browser side
// In the browser: call your own route, not OpenAI.
const res = await fetch("/api/ask", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ question }),
});
const { answer } = await res.json();

We built this into our Next.js test app. After next build, the OpenAI key wasn’t anywhere in the .next folder, because it is read when a request comes in, not at build time. The live-site script from step 5 came back clean. In a Lovable or Supabase app, the same idea is an Edge Function that reads the key from Supabase’s secrets. Our Lovable guide walks through that.

Two things to watch. First, the new route is a public address, and anyone can call it. If it doesn’t check who is asking and how often, a stranger can spend your AI credit through your own endpoint, which is the same leak with extra steps. Check for a signed-in user and add a rate limit before it calls the service. Second, on Vercel, store the key as a Secret rather than a Config variable. The Vercel docs say Secret values are write-only after saving, and that a Secret value of 32 characters or more is replaced with [REDACTED] if it shows up in build logs.

10

Put guardrails in place

You will make this mistake again, or your AI will. These make the next one cheaper:

  • Push protection. GitHub’s push protection for users is on by default and stops you pushing supported secrets to public repositories. For a private repo in an organisation, push protection can be turned on for the repository with GitHub Secret Protection.
  • Spend limits. OpenAI recommends monthly spend limits with hard enforcement, and notes that spend alerts alone don’t stop traffic. Anthropic suggests usage and spend limits, or careful auto-reload limits, as a safeguard against leaked keys. A limit won’t stop a leak. It caps the bill.
  • Smaller keys. Stripe recommends restricted keys with only the permissions each part of your app needs, and access policies so a key only works from your known servers. Firebase recommends separate, restricted keys for any non-Firebase Google API, and never putting the Gemini Developer API on a public key.
  • A check before every deploy. The two commands from step 4 take a second to run. Put them in a script you run after every build. If either prints anything, don’t ship.

Where LockShip fits

We built LockShip because the outside view is easy to forget and tedious to repeat after every change. You paste your app’s URL and prove you own the domain. The scan reads the page the way a stranger would: its inline scripts, up to eight script files it links to, and any source maps those scripts point to. Then it follows the chunk files those scripts name, the code that only downloads after a click or on another screen. It only follows files on your own domain, and it stops after 30 extra files so a scan stays quick. When we first ran it on the leaky Vite test app from this guide, it found the Stripe key and walked straight past the admin chunk. That bothered us enough to fix it, and it now finds the service_role key and the OpenAI key in that chunk.

It looks for Stripe secret and restricted keys (live and test) and webhook secrets, OpenAI and Anthropic keys, AWS access key IDs, and an AWS secret key when it is assigned by name or sits right next to a key ID. It also knows GitHub tokens (personal, OAuth and app tokens), Slack tokens, SendGrid and Resend keys, Replicate and Hugging Face tokens, private keys, Supabase secret keys, service_role JWTs (it decodes the role, as in step 3), Postgres and MongoDB connection strings with a password, and Google service account files. It tries /.env, /.env.local, /.env.production and /.git. It keeps only a short fingerprint of anything it finds, never the full value, and it never tries a secret key it finds.

It is just as important to say what it doesn’t do. It starts from the one page you give it. It follows the chunks your scripts name, but an app with hundreds of chunks, or one that builds file names in an unusual way, can still keep some code out of its reach, so step 5 is still worth your two minutes. It doesn’t know every provider’s key format, so step 1 is still your real list. It doesn’t flag keys that start with AIza, because from the outside a Gemini key looks exactly like the public Firebase key that is supposed to be there. It can’t see your git history, your server logs, or your mobile app. A clean result means nothing obvious showed up from the outside today, not that your app is safe.

The summary is free: a score and the single worst thing we found. The $39 Launch Safe certificate adds the full report with fix prompts for your builder, a PDF, and seven days of re-scans while you fix things. A passing re-scan gives you a badge for your site.

Want a second pair of eyes on your bundle?

Paste your app’s URL, prove you own the domain, and see which keys a stranger can already read. The summary is free.

Run a free scan

Before you rely on a result, read the disclaimer.

Questions people ask

Is the Supabase anon or publishable key in my JavaScript a leak?

No. Supabase’s API keys guide says the publishable key (sb_publishable_, or the older anon key) is safe to expose online and only reaches what Row Level Security allows. What protects your data is RLS on every table. The key that must never be in your page is the secret key (sb_secret_) or the older service_role key, which bypasses RLS entirely.

Is the Firebase apiKey in my config a leak?

No. Firebase says API keys for Firebase services are public by design: they identify your project, and Security Rules and App Check do the protecting. The exception is a key for the Gemini Developer API, which Firebase says must never be in your code or configuration files.

Is it safe to have my Stripe publishable key in the browser?

Yes. Stripe says publishable keys (pk_live_ and pk_test_) are safe to include in front-end code. They can identify your account and create tokens or PaymentMethods, but they can’t create charges or read account data. Secret keys (sk_) and restricted keys (rk_) are a different story: Stripe marks both as not safe to expose.

I deleted the key from my code. Am I safe now?

Not yet. Every copy that was already deployed, cached, or pushed to git still has it, and Next.js notes that public variables are frozen into the build at the moment you build. Assume the key was seen. Rotate it at the provider, then check its usage for anything you didn’t do.

Can I hide the key by encoding it or splitting it into pieces?

No. Whatever the browser can reassemble, anyone can reassemble, usually by setting a breakpoint or watching the request in the Network panel. The only fix is for the key to never reach the browser: your server holds it and makes the call.

Do I have to rewrite my git history after a leak?

Usually not. GitHub’s own guidance is to revoke or rotate the secret first, and says that once it’s revoked, rewriting history may not be warranted. Rewriting is slow, breaks other people’s clones, and doesn’t reach forks or copies you don’t control. Rotate first, then decide.

Keep reading

Sources

We checked each of these on 17 October 2026, and ran every command and script above against a Vite test app and a Next.js test app with fake keys. Providers change their key formats and dashboards often, so if something here disagrees with their docs, trust the docs and tell us at hi@lockship.dev.