API key exposure, explained
29 million secrets hit public GitHub in 2025, and 64 percent of the ones leaked in 2022 still work. Why a leaked key stays leaked, and the order to fix it in.
- security
- vibe coding
- engineering
A leaked API key is the least interesting vulnerability in your app and the most likely to end it. There is no exploit chain, no clever payload, and no skill involved: someone reads a string and uses it. What makes it worth a full post is that almost every instinct people have about fixing it is wrong, starting with the belief that deleting the key fixes it.
What counts as exposed
Wider than most people assume. A key is exposed if it is in any of these places, and the last three are the ones that surprise people:
- Committed to a repository, public or private
- Present anywhere in git history, even if the current files are clean
- Compiled into a frontend bundle, which is what happens to anything behind
VITE_in a Vite app orNEXT_PUBLIC_in a Next.js one - Printed into logs by an error handler that dumps the request or the config
- Sent to a browser inside a Server Component’s props or a JSON response
- Pasted into a chat, an issue, or a support ticket
“Private repository” is not a control. GitGuardian’s State of Secrets Sprawl 2026 found internal repositories are roughly six times more likely than public ones to contain hardcoded secrets, which is what you would expect: the perceived safety is exactly what relaxes the discipline. Repository visibility is also a setting someone can change later, and forks made while it was public do not change with it.
The numbers, and the one about AI
The same report counted 28.65 million new hardcoded secrets added to public GitHub commits during 2025, a 34 percent year-over-year rise and the largest single-year jump recorded. Secrets tied to AI services reached 1,275,105, up 81 percent, including 113,000 leaked DeepSeek keys.
The finding most relevant to anyone reading this: GitGuardian measured a 3.2 percent secret-leak rate in commits assisted by Claude Code, against a 1.5 percent baseline across all public GitHub commits. Roughly double. That is not an argument against coding with an AI assistant, and it is a precise statement of the risk you take on: the volume of code goes up, the review per line goes down, and secrets are the category where that trade lands hardest, because a secret in a diff looks like a working configuration value rather than like a bug.
The number that decides how you respond is a different one. As of January 2026, 64 percent of the valid secrets leaked in 2022 were still active and exploitable. Four years. Nobody rotated them.
Why deleting the commit does not work
This is the part that costs people their weekend.
Git does not store diffs, it stores objects, and every version of every file
that was ever committed is one. Removing the key in a new commit changes what
HEAD looks like and adds a blob; it removes nothing. The old blob is still in
.git, still reachable by its hash, still in every clone, every fork, and every
CI cache made in between.
Find out what is actually in yours:
git log -p --all | grep -nEo "sk-[A-Za-z0-9]{20,}|eyJ[A-Za-z0-9_-]{20,}|AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{36}"
Then, if you want the general answer rather than four patterns, run a scanner over the history rather than the working tree. VibeZero uses Gitleaks and TruffleHog for this, on the whole history rather than the checkout, and the two overlap deliberately: Gitleaks is fast and rule-driven, TruffleHog can verify a credential against the provider and tell you whether it is live. “Found a string shaped like a key” and “found a key that currently works” are different findings, and the second is the one that decides your next hour.
If it was pushed to a public host, assume automated collection got there first. Credential-harvesting bots watch the public commit firehose continuously; that is what a 28.65 million per year figure implies about the collection side.
Rotate first, then fix, then clean
The order matters more than any individual step, and most people do it backwards because rewriting history feels like the real work.
1. Rotate the credential. Revoke the old one at the provider and issue a new one. This is the only action that closes the exposure. Everything after it is hygiene. Do it before you write a line of code, before you rewrite history, before you tell anyone.
2. Check what was done with it. Look at the provider’s usage or audit log for
the exposure window. An OpenAI key with a spend spike, an AWS key with API calls
from an unfamiliar region, a Supabase service_role key with reads you cannot
account for. Rotation stops the future; only the log tells you about the past.
3. Move the value somewhere the client cannot reach. Server-side environment variable, and a server-side call. Not a variable with a client-exposed prefix, which is the same mistake with an extra step. Whichever generator wrote the code will happily put the call back in the frontend next time you ask for a feature, so this is a change to where the call lives, not just to where the string lives.
4. Then clean the history, if you still want to. With the key revoked, the
old blob is a dead string. Rewriting with git filter-repo or the BFG is now
optional tidiness rather than remediation, and doing it in this order means you
are not racing a rewrite against an attacker who already has the value.
Make the next one impossible to commit
Detection after the fact is a fallback. Two cheap controls stop most of it:
# .gitignore
.env
.env.*
!.env.example
An .env.example with the names and no values is the piece people skip, and it
is what stops the next contributor from inventing their own arrangement.
Second, fail the build when a required secret is missing, so that “undefined in production” never gets debugged by inlining the value temporarily:
const required = ["DATABASE_URL", "STRIPE_SECRET_KEY", "OPENAI_API_KEY"];
const missing = required.filter((k) => !process.env[k]);
if (missing.length) {
throw new Error(`missing required secrets: ${missing.join(", ")}`);
}
Third, scan on every commit rather than at review time. A pre-commit hook that runs Gitleaks costs a second and removes the entire class, because the failure you are preventing is not carelessness, it is a value that looked like configuration in a diff nobody read closely.
Where it shows up in generated apps
Consistently in the same three places.
On Bolt.new, through the VITE_ prefix that its own
database documentation recommends for a Supabase service role key. On
Lovable, through third-party API calls
written directly in the frontend because that is the shortest path from prompt to
working feature. On v0, through
NEXT_PUBLIC_, which Vercel now scans for at generation time and reports having
blocked over 100,000 insecure deployments because of.
And in the git history of all three, which is the part no generator checks and no build output reveals. That is why a secrets scan belongs on the full history rather than the working tree, and why it is one of the five layers rather than a step in a code review.
The window is the whole problem
Every other vulnerability class gives you a grace period: an attacker still has to find the endpoint, understand the flow, and build the request. An exposed key skips all of that, which is why it is the finding that blocks a release rather than joining a backlog.
The 64 percent figure is the one to keep. Most leaked secrets are not exploited because the owner responded; they are unexploited because nobody has gotten around to them yet, and the key still works. Rotation takes about five minutes and is the only step that ends it.