Free 15-minute launch audit — we tell you exactly what stands between you and going live.Claim a slot
Security

Your AI app is leaking API keys. Here is how to find them

AI coding tools put secrets in the browser because it makes the error go away. Here is the five-minute audit that finds them, and the right order to fix one.

GEGen2ProdProduction engineering

5 min read

A browser network panel showing an API key exposed in a request header

There are two kinds of API key, and telling them apart is the whole skill.

A publishable key identifies your project. A Supabase anon key, a Stripe publishable key, a Google Maps browser key — these are designed to sit in a bundle where anyone can read them, and something else (Row Level Security, a domain restriction, the Stripe API itself) decides what they can do.

A secret key authorises actions. An OpenAI key, a Stripe secret key, a SendGrid key, a Supabase service_role key. Whoever holds it can do what you can do, billed to you.

AI coding tools cannot reliably tell which is which. What they can tell is that a variable is undefined in the browser, and that prefixing it with NEXT_PUBLIC_ or VITE_ makes that error stop. That is the whole mechanism, and it is why this is the most common finding we have after RLS.

The five-minute audit

Do this in order. Steps one and two find almost everything.

1. Grep your source for the public prefixes. Every hit is a decision you are making, whether you know it or not:

grep -rn "NEXT_PUBLIC_\|VITE_\|PUBLIC_\|REACT_APP_" \
  --include="*.ts" --include="*.tsx" --include="*.js" --include="*.jsx" \
  --include="*.env*" . | grep -v node_modules

2. Grep for key shapes, prefix-agnostic. This catches the worse case: a literal pasted straight into a component.

grep -rnE "sk-[A-Za-z0-9_-]{16,}|sk_live_|rk_live_|AIza[0-9A-Za-z_-]{30,}|xox[baprs]-|ghp_[A-Za-z0-9]{30,}|eyJ[A-Za-z0-9_-]{20,}" \
  --include="*.ts" --include="*.tsx" --include="*.js" --include="*.jsx" . \
  | grep -v node_modules

3. Search the built bundle, not the source. This is the authoritative check, because it looks at what you actually shipped:

npm run build
grep -roE "sk-[A-Za-z0-9_-]{16,}|sk_live_|service_role" dist/ build/ .next/ 2>/dev/null

4. Check the deployed site. Open your live app, DevTools → Network, filter JS, and search across responses for sk-, sk_live, service_role. Then look at the request payloads: a call going straight from the browser to api.openai.com is carrying your key in a header, and DevTools will show it to you in plain text.

5. Check your git history. A key removed in a later commit is still in the repository, and public repositories are indexed:

git log --all -p -S "sk-" -- . | head -50

Which keys belong in a browser

KeyBrowser?What actually protects it
Supabase anon / publishableYesRow Level Security policies
Stripe publishable (pk_)YesStripe rejects privileged calls
Google Maps browser keyYesHTTP referrer restriction — set it
Analytics / Sentry DSNYesWrite-only by design
Supabase service_roleNeverNothing. It bypasses RLS entirely
OpenAI / AnthropicNeverNothing. Billed per token, to you
Stripe secret (sk_)NeverNothing. Full account access
SMTP / SendGrid / TwilioNeverNothing. Your domain, their spam

The pattern: a key is safe in a browser only when something outside the key constrains it. If the key itself is the authorisation, it cannot be public.

Fixing one, in the right order

Rotation comes first. Every hour you spend refactoring is an hour the old key still works.

  1. Revoke and reissue in the provider's dashboard. Not later — now.
  2. Check what it was used for. OpenAI usage by day, Stripe logs, Supabase auth logs. You are looking for volume you cannot explain.
  3. Put the new key somewhere server-side — see below.
  4. Purge the bundle. Rebuild, redeploy, and re-run step 3 of the audit to confirm it is gone.
  5. Set a budget cap on any metered provider. This is the difference between a scare and a five-figure invoice.

Has it already been used?

Rotate first, then answer this — the order matters, because investigating takes longer than revoking. What you are looking for in every case is volume or geography you cannot account for.

ProviderWhere to lookThe signal
OpenAI / AnthropicUsage, by day and by keySpend on days you did not ship
StripeDevelopers → Logs, filtered by keyCalls from IPs that are not yours
SupabaseLogs → API, and Auth logsservice_role requests from a browser
SendGrid / TwilioActivity and delivery statsSends you did not trigger, bounce spikes
AWS / GCPCloudTrail / Cloud Audit LogsNew regions, new resources, IAM changes

Two things worth knowing. Most providers only retain this for 30 to 90 days, so a key that has been public for a year cannot be fully audited — assume the worst and move on. And absence of evidence is genuinely reassuring here: these keys get abused for money, which is loud, not for stealth.

If you do find abuse, the order is: revoke, then check for anything the key created that outlives it — an added IAM user, a new webhook endpoint, a forwarding rule on your mail domain. A key is rotated in a minute; persistence installed with it is not.

Where the secret goes instead

Three patterns, in increasing order of how much infrastructure you already have.

A server route or serverless function. The browser calls you; you call the provider. Simple, and the right default when you have any server at all:

// The key lives in the server's environment and never leaves it.
export async function POST(req: Request) {
  const { prompt } = await req.json();
 
  const res = await fetch("https://api.openai.com/v1/responses", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ model: "gpt-4.1-mini", input: prompt }),
  });
 
  return Response.json(await res.json());
}

A Supabase Edge Function. If Supabase is already your backend, this is the shortest path: the secret lives in supabase secrets set, and the function verifies the caller's JWT before it spends your money.

Deno.serve(async (req) => {
  const auth = req.headers.get("Authorization");
  if (!auth) return new Response("Unauthorized", { status: 401 });
 
  const supabase = createClient(
    Deno.env.get("SUPABASE_URL")!,
    Deno.env.get("SUPABASE_ANON_KEY")!,
    { global: { headers: { Authorization: auth } } },
  );
 
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) return new Response("Unauthorized", { status: 401 });
 
  // Only now is it safe to touch Deno.env.get("OPENAI_API_KEY").
});

A Postgres function with security definer. For database work only — no external provider — a locked-down RPC lets the anon key do exactly one thing and nothing else. That is how the newsletter signup on this site works: anon has no privileges at all on the subscriber table and may only call one function.

Whichever you pick, two rules carry over. Authenticate before you spend — a proxy with no auth check is not a fix, it is a free API for the internet. And rate limit it, by user and by IP.

Stopping it coming back

Fixing today's leak is the easy half. AI tools will reach for the public prefix again on the next feature, so put something in the way:

  • .gitignore every .env* except .env.example, and check that it is actually working: git check-ignore -v .env.local.
  • Add a secret scanner to CI. gitleaks detect --no-git on the build output fails the deploy instead of shipping it.
  • Turn on your host's own scanning: GitHub secret scanning with push protection refuses the commit outright.
  • Set spend caps on every metered API, permanently.
  • Make "which of these keys is public, and what stops it being abused?" a question you ask on every review that touches a key.

The uncomfortable part

Most exposed keys are never abused. That is genuinely true, and it is also why this keeps happening: the feedback loop is broken. Nothing goes wrong for months, and then it goes wrong all at once, usually at the worst moment.

Five minutes with the greps above is a much better trade than finding out from a bill. If you would rather have someone go through the whole surface — keys, RLS, function auth, and the rest — a free 15-minute launch audit is where that starts, and what happens next is written up plainly.

Share

Your AI-built product deserves a real launch.

Start with a free 15-minute call. We'll tell you honestly what it takes to get you live — no pressure, no jargon.

Senior engineers only · NDA on request · Your code stays yours