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_modules2. 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_modules3. 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/null4. 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 -50Which keys belong in a browser
| Key | Browser? | What actually protects it |
|---|---|---|
| Supabase anon / publishable | Yes | Row Level Security policies |
Stripe publishable (pk_) | Yes | Stripe rejects privileged calls |
| Google Maps browser key | Yes | HTTP referrer restriction — set it |
| Analytics / Sentry DSN | Yes | Write-only by design |
Supabase service_role | Never | Nothing. It bypasses RLS entirely |
| OpenAI / Anthropic | Never | Nothing. Billed per token, to you |
Stripe secret (sk_) | Never | Nothing. Full account access |
| SMTP / SendGrid / Twilio | Never | Nothing. 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.
- Revoke and reissue in the provider's dashboard. Not later — now.
- Check what it was used for. OpenAI usage by day, Stripe logs, Supabase auth logs. You are looking for volume you cannot explain.
- Put the new key somewhere server-side — see below.
- Purge the bundle. Rebuild, redeploy, and re-run step 3 of the audit to confirm it is gone.
- 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.
| Provider | Where to look | The signal |
|---|---|---|
| OpenAI / Anthropic | Usage, by day and by key | Spend on days you did not ship |
| Stripe | Developers → Logs, filtered by key | Calls from IPs that are not yours |
| Supabase | Logs → API, and Auth logs | service_role requests from a browser |
| SendGrid / Twilio | Activity and delivery stats | Sends you did not trigger, bounce spikes |
| AWS / GCP | CloudTrail / Cloud Audit Logs | New 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:
.gitignoreevery.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-giton 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.



