All writing
5 min read

Your API keys are in the browser: secrets on the wrong side

The easiest leak in an AI-built app needs no attack at all: the secret key is sitting in the JavaScript the app sends to every visitor. This walks through finding an AI provider key and an admin token in a client bundle, what anyone can do with them, how to scan your own build for them, and the four fixes that move them server-side, demonstrated end to end on Kali Linux.

Asghar Ali · Chief Technology Officer, Kakushin.io LTD

Talk · Asghar Ali

This is the third in a short series where I take a realistic, AI-built app, break it the way an attacker would, and then fix it, all against my own lab and nothing pointed at anyone else's systems. Day one was a file upload that became a full server takeover. Day two was one number in the address bar that leaked another company's billing. Today I don't touch the server at all. I just read the JavaScript the app already sends to every visitor, and I come away holding its keys.

The flaw is secrets on the wrong side: API keys and tokens that belong on the server but were shipped in the client bundle. It is one of the easiest mistakes to make in an AI-built frontend, because the code works perfectly. The feature runs, the demo looks great, and the key sits in a file that anyone can open with two clicks.

A feature that feels like magic

The app is "Metrika," the same tidy analytics SaaS from the last two days. It has a new page: AI Insights. You type a question about your traffic, and a second later you get a clear answer: sessions are up, here's where growth came from, here's what to try next.

Here is how the generated frontend makes that happen:

// vulnerable: the browser calls the AI provider directly
const env = {
  VITE_AI_BASE: "/mock-ai/v1",
  VITE_AI_API_KEY: "sk-lab-DEMO-7f3a9c2e41b8d0",
  VITE_ADMIN_TOKEN: "mtk_admin_DEMO_51e0c9a7f2",
};
fetch(env.VITE_AI_BASE + "/chat.php", {
  method: "POST",
  headers: { Authorization: "Bearer " + env.VITE_AI_API_KEY },
  body: JSON.stringify({ prompt }),
});

To call the provider you need the provider's secret key. The model wired the browser to make that call, so the key had to travel to the browser. (In my lab the "AI provider" is a local mock and both values are fake demo strings, but the shape is the same one real apps ship.)

Reading the bundle

Anything you ship to the browser, anyone can read. I open the JavaScript bundle the page loads, index-7c41e9.js, and search it for API_KEY. There it is, in plain text.

Two things make people think this is safe, and neither one is:

  • Minifying doesn't hide anything. It shortens variable names. String values, including your key, come through untouched.
  • Your build tool doesn't hide it either. Vite inlines every variable that starts with VITE_, and Next.js does the same for NEXT_PUBLIC_. That prefix is the framework telling you this value will be public. Put a secret behind it and it is pasted straight into the bundle.

Right below the AI key there was something worse: an admin API token.

What anyone can do with them

The AI key. Anyone who copies it can call the provider as you. They can run their own app on your account, burn through your credits and use up your rate limits. The bill comes to you, and if they abuse it badly enough, the provider suspends the key and your real customers lose the feature.

The admin token. The frontend used it for an "export" button, so it shipped to everyone. One request with it:

curl -s -H "Authorization: Bearer mtk_admin_DEMO_51e0c9a7f2" \
     http://127.0.0.1:8000/api/admin/workspaces.php

and the admin API returned every workspace in the product (Acme, Northwind, Lumen, Orbit) with owners, emails, plans and revenue. No login and no password, just a string copied out of a JavaScript file.

How you'd find them in your own app

Scan what you actually ship, not just your source code. Secrets often never appear in the repo as plain text. They live in an .env file and get injected at build time, so a source-only review misses them. Build the app, then search the output:

grep -rnoE "(sk-|mtk_)[A-Za-z0-9_-]{8,}" dist/

In the lab, that one search over the built bundle finds both secrets. In a real project, use the key prefixes of the providers you actually use, look for long random strings, and run a proper secret scanner like gitleaks in your pipeline so a build with a key in it fails instead of shipping. And review every variable with a public prefix: if a variable is public, it isn't a secret.

The four fixes

The patched build has the same feature and gives the same answer, but its bundle has no key in it at all. Search it and you get Phrase not found. The browser no longer talks to the AI provider. It calls my own server, and only the server holds the key:

// fixed: the browser calls OUR endpoint; only the server holds the key
session_start();
$me = current_workspace();                       // who is asking?
if (!$me) { http_response_code(401); exit; }

if (too_many_requests($me, 20, '1 hour')) {      // rate limit per user
  http_response_code(429); exit;
}

$key    = getenv('AI_API_KEY');                  // server-side only, rotated
$prompt = substr(trim($input['prompt']), 0, 500);  // cap what we forward
echo json_encode(['answer' => ai_provider_chat($key, $prompt)]);

The old admin token is dead too. The patched admin API doesn't accept static tokens. It wants a signed-in admin session checked on the server, so the same request now returns 401 Unauthorized.

Four principles close this for good:

  1. Secrets never go in frontend code, and never in VITE_, NEXT_PUBLIC_ or any other public variable. If the browser can read it, so can everyone else.
  2. Put a small endpoint on your own server between the browser and the provider. It checks who is asking, rate-limits them, and caps what it forwards. As a bonus, that's the one place to add logging and abuse controls later.
  3. Rotate anything that ever shipped. Once a key has been in a public bundle, assume it has already been copied. Deleting it from the code doesn't un-publish it, and old bundles live on in caches and archives.
  4. Give every key the smallest scope and a spending limit. Use restricted keys, per-environment keys and hard budget caps, so a leak costs you minutes, not a fortune.

The rule underneath all four is one sentence: if it's in the browser, it's public. AI will happily wire a frontend straight to a paid API, because that's the shortest path to a working demo. Moving the secret behind your own server is the step you have to ask for by name.


I build these labs, break them, and fix them in the open every weekday. If you've shipped something AI-built and you'd like it checked properly, before someone else checks it for you, that is exactly the work I do. For the wider pattern, see the security risks nobody checks in vibe-coded apps.