Your API key is in the browser, and rotating it is the first move
There are two kinds of key in a typical AI-built app. One is meant to be public and is fine where it is. The other bills you per call, and if it reached the browser then every visitor has a copy, minified but perfectly readable. The difference is worth two minutes, because the fix has an order and most people get that order wrong.
Loïc Guillebeau9 August 2026Across the ten applications we audited in the three months to August 2026, this came third, behind access control and speed. It is also the one with the shortest path from finding to invoice: a leaked provider key is not a risk that might be exploited one day, it is a credential sitting in public that scanners find on a schedule.
One of your keys is supposed to be public
If your app runs on Supabase, the anon key in your frontend belongs there. That is Supabase's design: the key is public, and Row Level Security is what stands between it and your data. Finding it in the network tab is not a leak, and a checklist that tells you to hide it is wrong. Whether the wall behind it is standing is a separate question, and the more important one.
The keys this page is about are the other kind: an OpenAI key, a Stripe secret key, a Resend or SendGrid key, a Twilio token, a Supabase service_role key. None of them has a wall behind it. Each one is the whole authorisation, and anyone holding it can spend, send, or read as you.
Why it ends up in the bundle anyway
Ask for a feature and you get the shortest path to that feature working. "Summarise the notes with AI" becomes a call to OpenAI from the component that shows the notes, because that component is where the notes already are. The key has to come from somewhere, so it comes from an environment variable, and the app works on the first try.
The trap is in the build tool. Most of these apps are Vite projects, and Vite only exposes variables whose name starts with VITE_ to client code. Everything else is undefined in the browser. So the feature breaks, the error says the key is missing, and the obvious repair is to rename the variable until it stops being undefined. That rename is the exact moment a private key becomes a public one, and nothing warns you, because from the tool's point of view you asked for it.
// runs in the browser: the value is inlined into the bundle at build time
const res = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
Authorization: `Bearer ${import.meta.env.VITE_OPENAI_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ model: "gpt-4o-mini", messages }),
})Inlined at build time is the part that surprises people. The variable is not read when the page loads, it is pasted into the JavaScript file during the build. Rotating the key in your hosting dashboard changes nothing until you rebuild, and the old bundle keeps the old key for as long as anyone has it cached. Minification does not help either: it renames variables, it does not hide strings.
Check it yourself, in two minutes
Start from outside, with what a visitor actually receives. This pulls your deployed page, follows the scripts it loads, and looks for the shapes provider keys have.
APP=https://your-app.com
curl -s "$APP" \
| grep -oE '/assets/[^"]+\.js' \
| while read -r f; do curl -s "$APP$f"; done \
| grep -oE '(sk-|sk_live_|rk_live_|re_|SG\.|AIza|xoxb-)[A-Za-z0-9_.-]{12,}' \
| sort -uAnything that comes back is public. Then look in the repository, which catches the keys that are committed but not yet shipped, and the .env file that was never meant to be tracked.
grep -rnE '(sk-|sk_live_|rk_live_|re_|SG\.|AIza|xoxb-)[A-Za-z0-9_.-]{12,}|service_role' \
--include='*.ts' --include='*.tsx' --include='*.js' --include='*.jsx' \
--include='*.env*' --include='*.json' . \
| grep -v node_modules
git ls-files | grep -E '^\.env' # anything here is in the repository, foreverOne more, and it is the one people skip: a key deleted from a file is still in the history. If it was ever committed, treat it as public regardless of what the current code says.
git log --all --oneline -S 'sk-' -- . # commits that added or removed that stringRotate first, refactor second
The instinct is to move the call to the server, deploy, and feel better. That leaves the exposed key valid for however long the refactor takes, and the refactor is the slow part. Do it the other way round.
- Rotate the key in the provider's dashboard now. A key that has been in a public bundle is compromised, whether or not anyone has used it yet, and rotation is the only action that ends that.
- Read the usage and billing pages for the whole period it was exposed, not just today. Provider dashboards are also where you will see requests from regions and at hours that are not yours.
- Then move the call server-side: a Supabase edge function, an API route, whatever your stack already has. The secret lives in the server environment or your builder's secrets store, and never in a variable the client bundle can see.
- Rotating breaks every other consumer that held the old key. The cron job, the staging environment, the webhook you set up in March. Make the list before you rotate rather than during the outage.
- Do not spend the afternoon rewriting git history to remove the key. It is a reasonable hygiene task and it is not a remediation: the key has already been read. Rotation is what makes the old value worthless.
The one that is not a leak and costs exactly the same
Once the call is behind your own endpoint, the key is safe and the bill is not. An endpoint at /api/chat that takes a prompt and forwards it to OpenAI, with no check on who is calling, is a free OpenAI proxy with your card attached. It is a single fetch away from anyone who opened the network tab, and unlike a leaked key you cannot rotate it away.
So the server-side version needs the two things the browser version never had: a check that the caller is a logged-in user of your app, and a limit on how often any one of them can call it. That is ten lines. It is also the kind of thing nothing flags, because there is no secret in the file and no misconfiguration to find. The endpoint is simply doing what it was written to do, for the wrong people.
Why the deployed URL is the wrong place to look
A scanner pointed at your site can find a key in the bundle it downloads. That is a real check and it is worth running. What it cannot see is the key sitting in a file that is committed but not imported, the one in the .env that shipped to the repository, the second app in the monorepo that nobody deploys any more, or the open proxy endpoint that looks identical from outside to a properly guarded one.
That is the split this whole site is about: the browser shows you what is served today, and the repository shows you what exists. Grace reads the repository, so what comes back is the file, the line, the commit that introduced it, and whether the route it sits on is one your users actually reach. The audit is free, it is read-only, and it changes nothing. What you do with the list afterwards is your call.

Loïc Guillebeau
Founder, Grace · founder of Beyond the Brackets
Seven years running an engineering agency, ten AI-built applications audited in the last three months. Grace came out of both.
Want to know what else is in there?
Grace reads the repository and comes back with the architecture map, the risks ranked by severity, and a health score. It is free, it is read-only, and it changes nothing.
Keep reading