An exposed LLM API key is a provider credential embedded in public HTML, JavaScript, a source map or another browser-readable asset. Anyone can copy it without hacking the site. Revoke or rotate it first, review usage and billing, remove every public copy, then move the provider call behind a server-side endpoint so the replacement never reaches a visitor's browser.
Why a key in frontend code is already public
A browser cannot use a secret without receiving it. If JavaScript sends an Authorization: Bearer … header directly to an LLM provider, the value is visible in the Network panel before minification, obfuscation or a disabled source map enters the discussion. A visitor can also download the bundle and search it as text. The repository may be private and the deployment may use HTTPS; neither changes the fact that the client received the bearer credential.
This is a boundary mistake, not an advanced intrusion. LLM demos are especially prone to it because a developer can make the prototype work in minutes by importing an SDK into a React or Vue component and reading a build-time variable. The build tool replaces that variable with its value. The deployed file contains the key as an ordinary string, and a crawler, a browser extension or a person using developer tools can collect it.
SECRET, a private Git repository and a minified bundle do not create a security boundary.Where LLM API keys leak
The obvious place is an inline script or main JavaScript bundle. The less obvious places explain why deleting one line and deploying again is not enough.
| Public location | How the key gets there | What to inspect |
|---|---|---|
| HTML and inline JSON | A server serialises its whole configuration object into hydration data or a script tag | Page source, framework bootstrap objects and JSON-LD |
| JavaScript bundles | A frontend environment variable is replaced at build time | Main, route and lazy-loaded chunks |
| Source maps | Production maps include original source, comments and sometimes inlined environment files | Files named by sourceMappingURL |
| Runtime config | A container writes all environment variables to config.js so one image can serve several environments | env.js, runtime-config.js and JSON config requested at startup |
| Old assets and CDN caches | A new deploy stops linking a file but does not delete or invalidate the old object | Previous hashed bundles, release previews and cache history |
| Logs and error pages | A request header, exception or configuration dump prints the value | Browser-visible errors, deployment logs and observability exports |
Public .env files, backup archives and Git metadata are a related route: they expose server-side secrets without embedding them in the app. The containment process is the same, but the root cause and server rules differ; the exposed files guide covers that category.
The frontend environment-variable trap
Modern build tools distinguish variables that stay on the server from variables intentionally published to the browser. The public prefixes are not warnings; they are instructions to embed the value. In Next.js, NEXT_PUBLIC_ values are inlined into browser JavaScript at build time. Vite exposes VITE_ values through import.meta.env. Create React App used REACT_APP_, and several full-stack frameworks use a PUBLIC_ convention. These mechanisms are useful for a public analytics ID or application origin and unsafe for a bearer credential.
# unsafe: the build replaces this reference with the real value
VITE_OPENAI_API_KEY=sk-example-value
const client = new OpenAI({
apiKey: import.meta.env.VITE_OPENAI_API_KEY,
dangerouslyAllowBrowser: true
});
The dangerous option in that example accurately describes what is happening: the SDK cannot protect a credential it was told to use in the browser. Renaming the variable to LLM_SECRET helps only if the module is server-only. If a client component imports it, some frameworks fail the build, some replace it with an empty value and some bundle it through a configuration plugin. The reliable rule is architectural: the client calls your server, and only your server calls the provider.
How to scan a deployed site without collecting secrets
A responsible exposure scanner needs to answer “did we ship one?” without becoming a credential-distribution service. The LLM API Key Exposure Scanner fetches the entered page, the same-origin JavaScript files it references, explicitly named JavaScript or JSON chunks and linked source maps. It also requests a fixed list of common unlinked .env, Git, configuration, SQL dump, backup and runtime-config paths. Responses are content-verified rather than trusted by status code, because many applications return the home page with 200 for every nonexistent URL. It recognises provider prefixes and provider-specific assignments, but it never tests a candidate against an API. Before the result is built, every value is reduced to a short redacted fingerprint. A free result says only YES or NO; a Premium owner can see the stored provider family, public resource, line number and redacted value, never the credential. Premium deep mode repeats the page and client-asset inspection across every discoverable page it can crawl up to the plan limit.
Paid owners can scan a known private URL with one transient Authorization or Cookie header. It is kept in memory for that request, stripped before a cross-origin redirect and never written to the report. This is not a login bot: forms are not submitted and JavaScript is not executed. Premium users can choose a local project folder and scan it entirely inside the browser without uploading it. For a remote server, the optional Python utility runs inside the deployment directory, makes no network requests and can inspect Git patch history. A clean result therefore means “none was recognised in the stated coverage,” not “this organisation has never leaked a key.”
Prefix detection is not perfect. OpenAI-compatible services share formats, Google API keys can authorise non-Gemini services, and providers such as Mistral or Cohere may use opaque values without a distinctive prefix. A label match beside MISTRAL_API_KEY is useful evidence but needs confirmation in your own source. Placeholders must be ignored, or documentation pages full of strings such as YOUR_API_KEY become permanent false alarms.
What to do in the first hour
Order matters. Removing the visible string first feels productive, but it leaves every copied value valid while you deploy, purge caches and investigate.
- Preserve the finding without preserving the key. Record the provider, key name or redacted fingerprint, resource URL, deployment time and who owns the account. Do not paste the full value into a ticket, chat or incident document.
- Revoke or rotate it at the provider. If rotation is available, create a replacement for the server, switch legitimate traffic, then revoke the old value. If immediate revocation would cause an outage, restrict the old key's budget, models, network or project while completing the switch.
- Review usage and billing. Compare request volume, model choice, geography, IP data and timestamps with your application logs. Raise a billing or security case with the provider if activity is unexplained.
- Remove every public copy. Fix the source, rebuild, deploy, invalidate the CDN and delete old assets or preview deployments. Disable production source maps or publish them only to a private error-monitoring service.
- Search history and adjacent systems. Inspect Git history, build logs, container layers, issue trackers and shared snippets. Rotate any other credential exposed in the same configuration, even when the scanner did not recognise its format.
- Re-scan the live site and deployment. Confirm that the old fingerprint is gone from the page, client assets and common exposure paths, then run the Premium local-folder audit against the deployment directory; use its optional Python version when Git history is required.
If unauthorised use occurred, key rotation is containment, not the end of the incident. Preserve provider logs, determine the first and last abusive request, calculate spend, check whether the key could read stored files or fine-tuning data, and document which customers or data sets were reachable. The scope depends on the permissions that key carried, not merely on the fact that it existed.
The correct architecture: a narrow server-side proxy
Moving the same unrestricted request through a server hides the key but still lets anyone use your account. The server endpoint must enforce the decisions that a public client cannot be trusted to make.
Browser
POST /api/assistant { message }
|
v
Your server
authenticate user
enforce per-user quota and input limit
choose an allowed model and fixed parameters
attach server-only provider key
|
v
LLM provider
The browser should send the task, not an arbitrary provider request. Keep the model name, maximum output tokens, tool list, system instructions and account or project identifier on the server. Authenticate the caller when the feature is not deliberately public; for a public demo, apply a small anonymous quota, a total daily budget and abuse controls. Set provider-side spend limits and alerts as a final backstop, and use a different restricted key per application and environment so one leak has a bounded blast radius.
CORS is not authentication. Allowing only your website's origin can reduce casual browser abuse, but non-browser clients choose their own Origin header. Likewise, hiding an endpoint behind a serverless function does not make an unrestricted public route safe. The control is the policy your function enforces: who may call, how often, which operation and how much it may cost.
How to keep the next key out of the build
Prevention needs two complementary checks. Repository secret scanning catches a credential when it is committed or pushed. A deployment scan catches what actually reached production through CI variables, configuration injection or an old artefact. Neither replaces the other.
- Keep provider SDK imports in server-only modules. Many frameworks have a directive or package that makes the build fail when a client component imports server code; enable it.
- Use a secret manager supplied by the hosting platform and grant the runtime access to only the one secret it needs. Do not copy the value into a general configuration object.
- Maintain an allow-list of variables permitted to use public frontend prefixes. Fail CI when a name containing
KEY,TOKENorSECRETalso containsNEXT_PUBLIC,VITE,REACT_APPorPUBLIC. - Run a secret scanner on commits and pull requests, including history when a new repository is onboarded. Treat bypasses as security exceptions with an owner and expiry.
- Inspect final build artefacts before upload, because a clean source tree can still receive a secret from the CI environment. Search minified bundles and source maps, not only source files.
- Retain provider usage alerts and small budgets even after the architecture is fixed. A server log, backup or support paste can expose a key through another path.
The scan-report guide explains how to confirm evidence and order remediation, while the JavaScript library scanner covers a different client-side risk: code with published vulnerabilities rather than credentials embedded in it.
What a clean result does and does not mean
A clean website scan is valuable evidence that the inspected production page, linked client assets and documented common exposure paths are not publishing a recognisable key today. An authenticated scan adds only the exact private URL supplied. The local audit adds readable deployment files and optional Git patch history. None of those cover mobile binaries, desktop applications, unknown routes, preview environments, secret-manager contents, screenshots or support messages. They also cannot recognise an opaque secret with no useful context. Those limits appear in the report, because certainty that the test cannot support is itself a security problem.
Our position is simple: direct browser-to-provider calls are appropriate only with a provider mechanism explicitly designed for public, short-lived and narrowly scoped client credentials. A normal long-lived API key is not that mechanism. Put it on the server, constrain the endpoint around it, scan both the source and what shipped, and make rotation routine enough that an exposure is a contained incident rather than a company-ending surprise.