// GUIDE

Exposed API keys: what they are, and how to find and fix yours

Updated 2026-08-07 · 10 min read

The short answer

An exposed API key is a credential that has ended up somewhere the public can read it, usually the browser bundle, a "NEXT_PUBLIC_" variable, a committed ".env" file, or your git history. The critical distinction is public vs secret: publishable keys (Stripe "pk_", Supabase "anon") are meant to be seen, but secret keys (Stripe "sk_", Supabase "service_role", OpenAI, SMTP) grant real power and must stay server-side. This guide shows you how to find yours step by step, how to rotate safely, and how to keep secrets off the client for good.

Public keys vs secret keys, the distinction that matters

Not every key in your browser is a problem. API keys come in two families, and confusing them causes both false panic and real breaches.

Publishable (public) keys are designed to be seen by anyone. Stripe's "pk_" key and Supabase's "anon" key are examples, they identify your project but do not, on their own, grant access to anything sensitive (in Supabase's case, your Row Level Security policies are what actually protect the data). Finding these in your frontend is expected.

Secret keys are the opposite. Stripe's "sk_" key, Supabase's "service_role" key, your OpenAI key, and SMTP or database passwords all grant real, often unrestricted power, charging cards, bypassing database rules, spending money, sending mail as you. These must never reach the browser. The whole job of this guide is telling the two apart and getting the secret ones back behind your server.

Quick rule: "pk_" and "anon" are meant to be public. "sk_", "service_role", and anything called "secret", "private", or "password" are not, if one is in your browser, treat it as compromised.

Where AI tools leak secret keys

  • The "NEXT_PUBLIC_" prefix trap: in Next.js, any environment variable named "NEXT_PUBLIC_..." is deliberately baked into the browser bundle. AI tools sometimes prefix a secret this way to "fix" it being undefined on the client, which publishes it to the world.
  • Hardcoded in frontend code: the key is pasted directly into a component or client-side file because that is where the code calling the service lives.
  • The built JavaScript bundle: even without "NEXT_PUBLIC_", a secret imported into client code gets bundled and shipped. Viewing source or the network tab reveals it.
  • A committed ".env" file: the real ".env" (with actual values) gets committed to the repo instead of being ignored, so anyone with repo access reads every secret.
  • Git history: a key was committed once, then removed in a later commit. It looks gone, but it lives on in history forever and is still readable.
  • Client-side calls to third-party APIs: calling OpenAI, Resend, or a similar service directly from the browser forces the secret into the client, where it cannot be hidden.

How to find your exposed keys, step by step

  1. Open your live site, right-click, choose "Inspect", and open the "Sources" (or "Debugger") tab to see the loaded JavaScript.
  2. Search the loaded files for "sk_", "service_role", "secret", "api_key", and the names of services you use (e.g. "openai", "sk-"). Anything that matches a secret key is exposed.
  3. Open the "Network" tab, use your app normally, and inspect outgoing requests, a secret sent from the browser to a third-party API is exposed.
  4. In your codebase, search for "NEXT_PUBLIC_" and confirm every one of them is genuinely safe to be public; a secret behind that prefix is exposed.
  5. Check whether a real ".env" file is tracked by git (it should be listed in ".gitignore" and not appear in the repo).
  6. Search your git history for secrets, for example "git log -p -S sk_", a key that was committed and later deleted is still there. GitHub also sends secret-scanning alerts if it detects known key formats. [SOURCE NEEDED]

How to rotate a leaked key safely

Once a secret has been public, the only real fix is to rotate it, generate a new key and revoke the old one, because you cannot know who already copied it. Deleting the file, the commit, or the "NEXT_PUBLIC_" line does not undo the exposure.

Do it in an order that avoids downtime: first create the new key in the provider dashboard, then add it to your server-side environment variables and deploy, confirm the app still works on the new key, and only then revoke the old key. For payment keys, do this carefully and check the provider's guidance so you do not interrupt live charges. After rotating, run the find steps again to confirm the old value is truly gone from the bundle and history.

How to keep secrets server-side for good

The durable fix is architectural: secrets should only ever be read by code that runs on a server, never by code that runs in a browser. In practice, that means routing any call that needs a secret key through a backend, a Next.js API route or server action, a Supabase Edge Function, or a serverless function, and storing the secret in that environment's variables (or a secrets manager), not in client code.

So instead of the browser calling Stripe or OpenAI directly with a secret key, the browser calls your own endpoint, and your endpoint (which the public cannot read) uses the secret to talk to the service. The browser keeps only publishable keys. This is the pattern AI tools skip because the direct-from-browser version is faster to generate, and it is the one worth insisting on.

The pattern: secret stays on the server
// ❌ WRONG, secret key shipped to the browser
// This runs on the client, so the key is in the bundle for anyone to read.
const res = await fetch('https://api.openai.com/v1/...', {
  headers: { Authorization: `Bearer sk_live_REAL_SECRET_KEY` },
});

// ✅ RIGHT, the browser calls YOUR endpoint; the secret never leaves the server.
// Client code:
const res = await fetch('/api/generate', { method: 'POST', body });

// Server code (e.g. a Next.js API route / server action / Edge Function):
// process.env.OPENAI_API_KEY is read only here, never sent to the client.
const apiKey = process.env.OPENAI_API_KEY; // secret, server-side only
// ... use apiKey to call the third-party API, return only the result

Key takeaways

  • Publishable keys (Stripe "pk_", Supabase "anon") are meant to be public; secret keys ("sk_", "service_role", OpenAI, SMTP) must never reach the browser.
  • AI tools leak secrets via the "NEXT_PUBLIC_" prefix, hardcoded frontend code, the JS bundle, committed ".env" files, git history, and direct client-side API calls.
  • Find yours by searching the loaded JavaScript and network requests for "sk_"/"service_role"/"secret", auditing "NEXT_PUBLIC_" vars, and scanning git history.
  • Any secret that has been public must be rotated, create the new key, deploy it, verify, then revoke the old one.
  • Deleting a file or commit does not un-leak a key; the exposed value is compromised until it is revoked.
  • The durable fix is to route secret-using calls through a server endpoint so the browser only ever holds publishable keys.

Frequently asked

I found my Supabase anon key in the browser, is that a leak?

No. The anon key is a publishable key designed to be in the browser. What protects your data is Row Level Security, not the secrecy of that key. A leaked "service_role" key, by contrast, is a serious problem.

What does "NEXT_PUBLIC_" actually do, and why is it dangerous?

In Next.js, prefixing an environment variable with "NEXT_PUBLIC_" tells the build to embed it in the browser bundle. That is correct for publishable keys, but if a secret is given that prefix it becomes readable by anyone who views your site.

I deleted the commit that had my API key. Am I safe now?

No. The key was public the moment it was pushed, and it may remain in git history and in anyone's clone or cache. You must rotate the key, generate a new one and revoke the old, to be safe.

How do I call a service like OpenAI or Stripe without exposing the key?

Have the browser call your own backend endpoint (a Next.js API route, server action, or Supabase Edge Function), and let that server-side code use the secret key. The secret stays in server environment variables and never ships to the client.

Run a free security scan

Paste your app's link and get a plain-English A–F grade in about 60 seconds, plus the exact fix for every issue.

Free · No signup · Your code stays yours · Results in ~60s