What you need and what you will have at the end
You need your project's source on your machine, the deployed site, access to each provider dashboard whose keys you use, and a terminal with grep and git. Only scan projects you own.
At the end you will know which keys are public by design and which are not, you will have rotated every secret that leaked, and your app will call secret-bearing APIs only from a server route. You will also have a build scan that fails when a known secret pattern appears in browser output.
How keys reach the browser
- Environment variables with a public prefix are inlined into the JavaScript bundle at build time. Next.js does this for variables prefixed NEXT_PUBLIC_, and Vite does it for VITE_. Vite's docs say plainly that these variables should not contain sensitive information such as API keys.
- A call to a paid API, such as an LLM or payment provider, is written directly in a React component instead of a server route, so the key must be in the client to work.
- A key is committed to the repository, and the repository is public or later becomes public.
- Source maps are published with the production build and expose original source, including hard-coded values.
- A chat with an AI tool includes a real key that you pasted to help it debug, and the key then lands in generated code.
Step 1: Sort your keys into public by design and secret
Not every key is a problem. Stripe publishable keys start with pk_ and Stripe documents them as safe to expose in front-end code. The Supabase publishable (anon) key and Firebase web config values are also designed to be in client code, and their safety depends on server-side rules such as RLS and security rules.
Stripe secret keys start with sk_ and restricted keys with rk_, and Stripe lists neither as safe to expose. LLM provider keys, the Supabase secret (service_role) key, database URLs, private key files and webhook signing secrets also belong only on a server. Write down every key your app uses and mark each one public or secret before you scan.
| Credential | Where it may live | Note |
|---|---|---|
| Stripe pk_ key | Browser or server | Safe to expose per Stripe docs |
| Stripe sk_ or rk_ key | Server only | Restricted keys limit the damage if leaked |
| Supabase publishable (anon) key | Browser or server | Safe only with correct RLS |
| Supabase secret (service_role) key | Server only | Bypasses every RLS policy |
| LLM provider key | Server only | Leaked keys spend your money |
| Database connection string | Server only | Grants direct database access |
Step 2: Scan your source tree
Run the search below from your project root. It looks for common secret shapes and skips dependencies. Every hit is a candidate, not a confirmed leak: check whether the match is a real credential, a placeholder, or an intentionally public key.
Also confirm your environment files are not tracked. If git ls-files prints a .env file, that file is in your repository and its contents must be treated as exposed.
grep -rEn "sk_live_|rk_live_|sk-[A-Za-z0-9_-]{20,}|service_role|BEGIN (RSA |EC )?PRIVATE KEY|postgres(ql)?://|mongodb\+srv://" . \
--exclude-dir=node_modules --exclude-dir=.git --exclude-dir=.next --exclude-dir=dist
git ls-files | grep -E "(^|/)\.env"
git check-ignore -v .env .env.localStep 3: Scan what you actually ship
The source can be clean while the build is not, because public-prefixed variables are substituted at build time. Build the app and search the browser output. For Next.js that is the .next/static folder, and for Vite it is dist. Any match in these folders is public to every visitor.
Then open your deployed site, open developer tools, and use the search across all files in the Sources panel for the same patterns. Finally, check whether source maps are being served by requesting one of your script URLs with .map added. A 200 response means your original source is downloadable.
npm run build
grep -rEl "sk_live_|rk_live_|sk-[A-Za-z0-9_-]{20,}|service_role|BEGIN (RSA |EC )?PRIVATE KEY" .next/static dist 2>/dev/null
curl -sI https://your-app.example/assets/index-abc123.js.map | head -n 1Step 4: Search git history
A key that was deleted in a later commit is still recoverable from the earlier one. Search all branches for the key shapes you care about. Any hit in history means the key is compromised even though the current code is clean.
GitHub's documentation on secret scanning gives the same advice for exposed credentials: revoke them. Removing a secret from history is slow and unnecessary once the credential is invalidated, so treat rewriting history as optional cleanup and revocation as the fix.
git log --all --oneline -S'sk_live_' -- .
git log --all --oneline -S'service_role' -- .Step 5: Rotate every secret that leaked
Stripe's Dashboard rotation flow lets both the old and new key work for a grace period of up to 7 days if you choose an expiry, which allows a gradual migration, and it recommends checking the old key's request logs and expiring it once its traffic has stopped. If a key is confirmed compromised, choose immediate expiry instead and accept a short outage.
Webhook signing secrets are not API keys. If one leaked, roll it from the webhook endpoint's settings, not from the API keys page.
- 1Create a new key in the provider dashboard and deploy the app using it, supplied from a server-side environment variable or your host's secrets store.
- 2Confirm the app works with the new key in production before you retire the old one.
- 3Revoke or expire the old key. Deleting it from your code does nothing, because the old value stays valid until the provider revokes it.
- 4Read the provider's request or usage logs for calls you do not recognize, and set a spending limit where one is offered.
- 5Update every other place the old key was copied: CI settings, preview environments, teammates' local files and any third-party tool you shared it with.
Step 6: Fix the design, not just the key
Move any call that needs a secret into a server route, edge function or backend, and have the browser call that endpoint. The route holds the key in a non-public environment variable, checks who is calling, and limits how often they can call.
Without authentication and rate limiting, your endpoint becomes a free proxy to your paid API. The sketch below is a Next.js route handler: the key is read from a server-only variable, and the request is refused unless your own auth check passes. Replace getUser with your real session check.
import { NextResponse } from "next/server";
import { getUser } from "@/lib/auth";
export async function POST(request: Request) {
const user = await getUser(request);
if (!user) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
const { text } = await request.json();
if (typeof text !== "string" || text.length === 0) {
return NextResponse.json({ error: "bad request" }, { status: 400 });
}
const upstream = await fetch("https://api.example-llm.com/v1/complete", {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${process.env.LLM_API_KEY}`,
},
body: JSON.stringify({ prompt: text }),
});
return NextResponse.json(await upstream.json(), { status: upstream.status });
}Common mistakes
- Renaming NEXT_PUBLIC_OPENAI_KEY to a non-public name but leaving the old value valid. The leaked value still works until it is revoked.
- Rotating the key in one environment and forgetting preview deployments or CI, which keep serving the old one.
- Removing the key from the repository and calling it fixed without revoking it.
- Assuming a private repository is safe forever. Repositories get made public, forked and shared, and contributors keep copies.
- Using a full-permission Stripe secret key where a restricted key would do, which widens the damage of any leak.
- Setting the variable only in the hosting dashboard and forgetting that public-prefixed values are frozen into the bundle at build time, so changing them later requires a rebuild.
How to verify
| Check | How | Pass condition |
|---|---|---|
| No secrets in source | Step 2 grep | No hit is a real secret |
| No secrets in build output | Step 3 grep on .next/static or dist | No matches |
| Source maps not public | Step 3 curl on a .map URL | Not a 200 response, or maps contain no secrets |
| Old keys dead | Call the provider with the old key | Authentication error |
| New route protected | Call the server route with no session | 401 response |
| Env files untracked | git ls-files | grep .env | No output |
Keep it working
Add the Step 3 build scan to your release script or CI so a leaked pattern fails the build. Turn on your git host's secret scanning where it is offered, since GitHub runs it automatically on public repositories. Review provider dashboards whenever a teammate leaves, and prefer restricted keys with the minimum permissions for every new integration.
Frequently asked questions
Is a Firebase apiKey in my web app a leaked secret?
No. The Firebase web config identifies your project and is designed to be in client code. What protects your data is Security Rules, plus App Check to reduce abuse from clients that are not your app. Fix open rules instead of trying to hide the config.
I deleted the key from GitHub. Am I safe?
No. The old commit still contains it, and automated scrapers may have copied it already. Revoke the key in the provider dashboard. GitHub's own guidance is that revoking is the remediation and rewriting history is optional.
Can I keep a secret key in a NEXT_PUBLIC_ variable if the site requires login?
No. The value is inlined into the JavaScript sent to the browser, and anyone who can load the app, including any signed-up user, can read it. Put the call in a server route that reads a non-public variable.
How do I stop a leaked-key incident from costing money?
Use restricted or scoped keys, set spending limits where the provider offers them, and monitor usage. Rotating the key stops new charges, but usage that already happened is only visible in the provider's logs and billing.