AI QA Monkey
AI Security Intelligence
Fix Guide

Exposed API Keys in Client-Side Code: Rotate, Remove, Re-architect

If our scanner flagged a secret in your HTML or JavaScript, this guide is the full response — from the first ten minutes through to the architectural change that stops it happening again. It is written to be useful whether you have run a security programme for a decade or are looking at your first finding.

The Rule That Has No Exceptions

Anything your browser can use, your visitor can read. There is no such thing as a hidden value in client-side code.

This is the single idea the rest of the guide rests on, and it is worth being blunt about because a great deal of engineering effort is wasted trying to defeat it. Minifying the bundle, base64-encoding the string, splitting the key across several variables, fetching it at runtime from your own API, obfuscating the whole file — every one of these fails against the same attack: your code must eventually assemble the real value in order to use it. At that moment it is visible in the network tab, in memory, or simply by setting a breakpoint.

Attackers do not do this by hand. Automated crawlers continuously download public JavaScript bundles and run pattern matching for credential formats, and a leaked key is typically found and tested within hours. So the practical rule is simple: a credential that reaches the browser must be treated as published, and the response is to rotate it, not to hide it better.

What our scanner looks for

We scan your inline HTML and your same-origin JavaScript bundles for high-confidence credential formats: private key blocks (RSA, EC, OPENSSH, PGP), AWS access key IDs (AKIA…), Stripe live secret keys (sk_live_…), SendGrid keys (SG.…), GitHub tokens (ghp_, gho_, ghu_, ghs_, ghr_), Slack tokens (xox[baprs]-…) and Google API keys (AIza…). These patterns are distinctive enough that a match is rarely a false positive.

Step 1: Classify — Is This an Emergency?

Before you touch anything, decide what you are dealing with. Not every exposed key is a crisis, and treating a publishable key as a breach wastes hours you may need elsewhere. Ask one question: what can someone do with this value that they could not do without it?

Tier 1 — drop everything. These grant direct, billable or destructive access to infrastructure and data:

  • Private key blocks — TLS keys, SSH keys, signing keys. Full impersonation or server access.
  • AWS access key IDs — depending on the attached IAM policy, this can mean reading your S3 buckets, spawning compute at your expense, or full account compromise. Crypto-mining abuse on leaked AWS keys is automated and fast.
  • Stripe secret keys (sk_live_) — read customer records, issue refunds, create charges. Never belongs anywhere near a browser.
  • SendGrid / mail provider keys — send mail as your domain. Beyond the phishing risk, an abused key can get your sending domain blacklisted, which damages deliverability for months.
  • GitHub tokens — read or write your source code. A write-scoped token is a supply-chain incident, not just a leak.
  • Slack tokens — read internal conversations and post as your workspace.

Tier 2 — fix promptly, but breathe. Google API keys (AIza…) are the common case here. The realistic damage is quota theft and an unexpected bill rather than data loss — provided the key is properly restricted. If it has no HTTP referrer restriction and no API restriction, treat it as Tier 1: an unrestricted Maps or Cloud key can be used by anyone, for anything the key permits, at your expense.

Tier 3 — not a finding. Publishable identifiers designed for the browser. See the section below before you conclude a key belongs here, because the distinction is entirely about the provider-side configuration, not about the key's name.

Step 2: Rotate, Provider by Provider

Rotation comes before code changes, before investigation, before the post-mortem. The leaked value is likely already indexed; every minute it remains valid is a minute of exposure. Here is where to go for the providers we detect:

  • AWS — IAM → Users → Security credentials. Create a new access key, deploy it, then deactivate and delete the old one. Deactivate first if you can tolerate the downtime: it is reversible, deletion is not. Then check CloudTrail for use of the exposed key.
  • Stripe — Developers → API keys → Roll key. Stripe supports rolling with a short grace period so live traffic is not interrupted. Afterwards, review Events and Logs for API calls you cannot account for.
  • GitHub — Settings → Developer settings → Personal access tokens → Revoke. Re-issue with the narrowest scope that works, and prefer a fine-grained token limited to a single repository.
  • SendGrid — Settings → API Keys → Delete, then create a replacement with restricted access rather than Full Access. Check Activity for mail you did not send.
  • Slack — Reinstall the app or rotate the token from the app configuration page; legacy tokens should be retired rather than re-issued.
  • Google Cloud — APIs & Services → Credentials → Regenerate key. While you are there, add an HTTP referrer restriction limited to your domains and an API restriction limited to the specific APIs you use. An unrestricted Google key is the single most commonly abused credential on the web.
  • Private keys — generate a new keypair, reissue or re-enroll wherever the old public key was trusted, and revoke the old certificate if one was issued from it.
Rotate in the right order

Create the new credential → deploy it → verify the application works → only then revoke the old one. Reversing this sequence turns a security finding into an outage, which is how rotation gets postponed indefinitely at many organizations.

Step 3: Check Whether It Was Already Abused

Rotation stops future abuse; it tells you nothing about what already happened. Define the exposure window — from the deploy that first shipped the key, to the moment you revoked it — and review provider logs across exactly that period:

  • Billing and usage. The most reliable signal. Unexplained spend, a spike in API calls, or compute in a region you have never used.
  • Audit logs. AWS CloudTrail, Stripe Events, GitHub audit log, SendGrid Activity. Look for calls from unfamiliar IP addresses or user agents, and for actions your application never performs.
  • Data access patterns. Bulk reads, unusual exports, or listing operations against storage your app only ever writes to.

If you find evidence of use, you have moved from a vulnerability to an incident, and your disclosure obligations may change. That is the point to bring in whoever owns incident response — and if you do not have a process yet, our incident response plan template gives you a working one.

Step 4: Remove It From the Bundle and From History

Now clean the code. Two separate places need attention, and people routinely stop after the first.

The bundle. Delete the literal from source, replace it with a call to your backend (see the next section), and rebuild. Then verify against the deployed site rather than your local build:

# Confirm the value is genuinely gone from what you actually serve
curl -s https://yourdomain.com/assets/app.js | grep -c "AKIA"

# And from the HTML itself
curl -s https://yourdomain.com/ | grep -Eo "(AKIA|sk_live_|ghp_|AIza)[A-Za-z0-9_-]+"

Version control history. Deleting a line does not remove it from git — the value remains recoverable in every prior commit, and in every fork and clone. Purge it properly:

# Recommended: git-filter-repo (faster and safer than filter-branch)
pip install git-filter-repo
git filter-repo --replace-text <(echo "AKIAEXAMPLEKEY==>REMOVED")

# Alternative: BFG Repo-Cleaner
bfg --replace-text passwords.txt

# Then force-push and tell every collaborator to re-clone
git push --force --all
History rewriting is disruptive — plan it

A force-push rewrites commit hashes for everyone. Coordinate with your team, do it when the repository is quiet, and remember that any fork, open pull request, or CI cache may still hold the old objects. This is exactly why rotation is step 2 and history cleanup is step 4: rotation is what actually protects you, history cleanup is hygiene.

Step 5: The Backend Proxy — the Actual Fix

Everything so far treats the symptom. The cure is architectural: the credential never leaves a server you control. Instead of the browser calling the third-party API directly with a secret, it calls a narrow endpoint on your own backend, and that endpoint calls the third party.

Before — the secret is in the browser:

// public/app.js — shipped to every visitor
const RESULT = await fetch("https://api.example.com/v1/data", {
  headers: { "Authorization": "Bearer sk_live_REALSECRET" }
});

After — the browser calls you, and you call them:

// public/app.js — no credential of any kind
const RESULT = await fetch("/api/data", { credentials: "same-origin" });
// server: Node / Express
app.get("/api/data", requireSession, rateLimit, async (req, res) => {
  const upstream = await fetch("https://api.example.com/v1/data", {
    headers: { "Authorization": `Bearer ${process.env.API_SECRET}` }
  });
  const data = await upstream.json();
  res.json(pickSafeFields(data));   // return only what the UI needs
});
// server: Next.js App Router — app/api/data/route.js
export async function GET() {
  const upstream = await fetch("https://api.example.com/v1/data", {
    headers: { Authorization: `Bearer ${process.env.API_SECRET}` }  // no NEXT_PUBLIC_ prefix
  });
  return Response.json(pickSafeFields(await upstream.json()));
}
<?php
// server: plain PHP — /api/data.php
require_once __DIR__ . '/config.php';   // stores the key outside the web root
header('Content-Type: application/json');

$ch = curl_init('https://api.example.com/v1/data');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . API_SECRET],
]);
echo json_encode(pick_safe_fields(json_decode(curl_exec($ch), true)));

A proxy is only as good as the guards on it. Three are non-negotiable, because a wide-open proxy simply relocates the abuse rather than preventing it:

  1. Authentication — if the underlying data is user-specific, verify the session on every request. Otherwise you have built a public API to a paid service.
  2. Rate limiting — cap requests per session and per IP so nobody can drain your quota through your own endpoint. Our API rate limiting guide covers workable defaults.
  3. Response narrowing — return only the fields the interface actually renders. Proxying the upstream response verbatim frequently leaks internal identifiers, pricing data or other users' records.

Which Keys Are Safe in the Browser

Some credentials are designed to be public, and treating them as incidents burns credibility. The distinction is not the name of the key — it is whether the provider enforces restrictions on it:

  • Stripe publishable keys (pk_live_, pk_test_) — intended for the browser. They can tokenize a card but cannot read customers or move money.
  • Google Maps browser keys — safe only with an HTTP referrer restriction and an API restriction applied. Without them, safe becomes billable.
  • Firebase / Supabase anon keys — designed to ship publicly, but they are only a safe boundary if security rules or row-level security are actually written. The anon key is an identifier; your rules are the security. An anon key over an unprotected table is a full database leak.
  • Analytics and product site IDs — write-only identifiers with no read access.

A reliable heuristic: if the provider documents the key as publishable, and you have applied the restrictions the provider recommends, it belongs in the browser. Everything else does not. Names containing secret, private, service role or server are never candidates.

Why Your Environment Variable Leaked Anyway

This surprises experienced engineers regularly, so it deserves its own section. Front-end build tools perform a compile-time substitution on variables carrying a public prefix — the literal value is written directly into the JavaScript that ships. The variable lives safely in your .env; the value does not stay there.

  • Next.js — NEXT_PUBLIC_* is inlined into the client bundle. Anything without the prefix stays server-side only.
  • Create React App — REACT_APP_* is inlined. There is no server side; every variable it reads is public by construction.
  • Vite — VITE_* is inlined via import.meta.env.
  • Astro / SvelteKit — PUBLIC_* is inlined; SvelteKit's $env/static/private is the server-only counterpart.
  • Nuxt — everything under runtimeConfig.public is serialized into the page payload.

The practical rule: the public prefix is a declaration, not a protection. Adding it to a secret does not make the secret safe — it publishes it. If a value needs the prefix in order to work in a component, that component is the wrong place for the call, and it belongs behind the proxy from the previous section.

Step 6: Make the Next Leak Impossible

Rotation fixes today. Automation fixes the class of problem. Three layers, each cheap:

Layer 1 — block it before it is committed. A pre-commit hook catches the mistake at the only point where cleanup is free:

# gitleaks — scan staged changes before every commit
brew install gitleaks   # or: docker run zricethezav/gitleaks

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.18.0
    hooks:
      - id: gitleaks

Layer 2 — block it before it merges. Run the same scanner in CI so a leak in a pull request fails the build. Enable your platform's native protection too — GitHub Secret Scanning with Push Protection rejects a recognized credential at push time and is free on public repositories.

Layer 3 — verify what you actually shipped. Scanning source is not the same as scanning output, because build tools inline values after your scanner has run. Grep the built bundle in CI:

# Fail the build if a known secret pattern reaches the output bundle
if grep -rEq "(AKIA[0-9A-Z]{16}|sk_live_[0-9a-zA-Z]{24,}|ghp_[0-9a-zA-Z]{36})" ./dist; then
  echo "Secret detected in build output — failing"; exit 1
fi

Finally, close the loop from the outside: a scheduled external scan of your live site checks what visitors actually receive, which is the only view that reflects your CDN, your tag manager, and every third-party script that can inject code after your build pipeline has finished.

Check What Your Site Is Exposing Right Now

Free scan — detects credentials in your HTML and JavaScript bundles, exposed .env files and source maps in about 60 seconds, with a copy-paste fix for each finding.

Scan Your Site Now

Frequently Asked Questions

Can I hide an API key in JavaScript by obfuscating it?

No. Obfuscation, minification, base64 encoding and splitting a key across variables all fail against the same trivial attack: the browser must assemble the real value to use it, so it is readable from memory, the network tab, or a breakpoint. If a credential reaches the browser, it is public. Keep it on a server and expose a narrow endpoint instead.

Which API keys are safe to include in front-end code?

Keys explicitly designed as publishable: Stripe publishable keys, Google Maps browser keys with referrer and API restrictions applied, Firebase or Supabase anon keys with security rules written, and analytics site IDs. Anything named secret, private, service role or server key is never safe in a browser.

I rotated the key. Do I still need to clean git history?

Rotation is the urgent step and it neutralizes the leaked value. History cleanup is still worthwhile — a stale secret in an old commit causes confusion, may breach a compliance requirement, and can be reintroduced by a revert or cherry-pick. Rotate first, purge history at a calmer pace.

Why did my key leak if I stored it in an environment variable?

Front-end build tools inline any variable with a public prefix directly into the shipped bundle — NEXT_PUBLIC_, REACT_APP_, VITE_, PUBLIC_. These are compile-time substitutions, not runtime secrets. The name looks safe in your .env; the literal value ends up in the JavaScript every visitor downloads.

How quickly are leaked keys actually exploited?

Fast. Automated crawlers continuously harvest public bundles and code repositories and test what they find, so exploitation is frequently measured in hours rather than weeks. This is why rotation precedes investigation: you stop the bleeding first, then determine what happened.

Check Your Website Right Now

Run a free automated security scan — 100 checks in 60 seconds. No signup required.

Run Free Security Scan →