A case that indie developers without a security background commonly run into, with every identifying detail removed and turned into a lesson. There are no attack reproduction steps here. The goal is defense, so the same mistake doesn't happen to you.
- Class
- API key leak / fraudulent billing
- Severity
- Critical (a published CVSS 10.0 abused)
- Root cause
- A pre-auth RCE in the web framework left unpatched for months
- Leak scope
- Every secret in the environment
- Execution scope
- Stayed inside an unprivileged container (host root unharmed)
- Permanent fix
- ①upgrade to patched version ②rotate all keys ③machine CVE monitoring
“Stopped the charges” ≠ “handled”
Stopping the bill is a temporary measure. Closing the leak path is a separate task. You're only done when you've done both.
What happened (timeline)
Day 0 — shipped & running
An app built with AI's help was shipped and running in production.One day — the bill spikes
The cloud-AI bill suddenly jumps. Bulk jobs on a model that would never be used, doing work that wasn't theirs.Investigation — third-party abuse
"Our app ran wild" doesn't explain it. A third party was running it with a stolen key.Tracing — investigating the leak path
"So where did the key leak from?" This is where the actual investigation started.
Why it wasn't caught at first
In the order the mistakes were made — because the mistakes are the lesson.
Decided on a cause too early
→ Before deciding "my fault" or "their fault," look at the data first.
A clean grep almost ended the search
Hiding the symptom felt like fixing it
A clean grep is not the all-clear
Even with no key in any file, a vulnerability can pull environment variables out of a running process. Leaks happen at runtime (RCE, HTTP headers), not just in files. See What is RCE.
The actual cause: a published CVSS 10.0 left unpatched
A strange signature in old access logs quickly matched threat intelligence. The web framework version range in use had a published pre-auth RCE (CVSS 10.0) that was already being exploited in the wild — and it had run unpatched for months.
- This wasn't a passive bug — it was an attacker executing code on the server and exfiltrating the environment.
- One thing limited the damage: execution stayed inside an unprivileged container (host root wasn't taken). The breach review found no persistent backdoor, miner, or C2 — the confirmed impact was secret theft.
- Because the internal DB was reachable, the response assumed the DB contents had leaked too.
Lesson: when something behaves strangely, suspect a known CVE first. Don't assume it's your own bug.
Narrow down the leak: where did that key live?
When grep finds nothing, change how you search. List where the abused key was written down, and the candidate leak paths shrink. If the abused key only ever existed in the production server's environment variables, for example, suspect the production server or the hosting provider, not Git or the browser.
| Where it leaks | Common cause | How to check your own setup | How to close it |
|---|---|---|---|
| Git history / public repos | Committing .env, hardcoding keys | Scan the full history with gitleaks git. Check GitHub secret scanning alerts | Revoke first, then block it before commit |
| JavaScript shipped to the browser | A key in a NEXT_PUBLIC_ variable, calling the API straight from the browser | Search the build output for the key's prefix | Move the API call to server-side code |
| A public web folder | .env or backups placed in the public folder | Open /.env on your own site and confirm it returns 404 or 403 | Move secret files outside the public folder |
| Logs / error pages | Shipping with debug output on, logging request headers as-is | Check production debug settings and whether Authorization and similar appear in logs | Turn off debug output and mask secrets in logs |
| The running server | A published vulnerability (such as an RCE) left unpatched | Compare the version actually running with the fixed version in the advisory | Upgrade, then replace every secret in the environment |
| The hosting provider | A breach on the provider's side | Check the provider's security bulletins and email notices | Store secrets so values can't be viewed later, and rotate |
| Your development machine | A malicious package or extension | Check whether package or extension versions you installed match a published compromise | Remove that version and replace secrets that were on the machine |
This case was the "running server" row. The bottom four rows are paths that leave no trace in your code or Git. OWASP's Secrets Management Cheat Sheet also warns that environment variables are often readable by other processes in the same environment and can end up in logs or dumps. So when one key is abused, treat every secret stored in the same place as leaked.
Separate keys make the investigation faster
If you use a different key per service and per environment (production vs development), the abused key alone tells you which row of the table you're in. Reuse one key everywhere and you have to suspect every location. How to split keys is covered in When a stolen API key gets your OpenAI account banned.
Count vulnerabilities by the version actually running
Checking the other dependencies, "8 more vulnerable dependencies" was reported. That was also wrong. They'd been counted by the lower bound in package.json (the ^ caret range). The truly dangerous ones were only the 2 pinned dependencies left behind.
Check your own running versions
See the versions actually resolved, in the lockfile or the running container.
# npm: see what's actually installed
npm ls next react react-dom
# inside a running container
docker exec <container> npm ls <package>The numbers here are the versions actually running, not the ^ in package.json.
How to check your own setup
Whether you've just noticed a strange bill or nothing has happened yet, you can check in this order. Every step only looks at your own account and server.
See which key was used, and when
Check the files shipped to the browser
NEXT_PUBLIC_ are inlined at build time into the JavaScript sent to the browser. After a build, search the output for your key's prefix, for example grep -rl "sk-" .next/static. Anything found there was readable by anyone.Confirm secret files aren't reachable from the public folder
curl -sI https://example.com/.env against your own site (replace example.com with your domain) and confirm the status on the first line is not 200 (it should be 404 or 403). More in auditing secret files in public directories.Compare the production version with the advisory
npm ls inside the production container to see the version. osv-scanner scan -L pnpm-lock.yaml checks whether your lockfile includes versions with known vulnerabilities.Check logs for secrets
gitleaks dir --redact on the folder where your app's logs are stored to find keys written into logs. --redact keeps the found values themselves off the screen.Check limits and alerts
What was done first (reproducible steps)
Revoke the abused key immediately
Upgrade the framework to the patched version
Rotate every secret
Breach review
Account-side defense
Prevention: monitor CVEs automatically
Boiled down, this incident was "a human overlooked a published CVSS 10.0." Manual checks always miss things. Automated monitoring catches them.
Dependency scanning you can start today
Free to start — one step in CI.
# OSV scanner (Google) checks your lockfile
osv-scanner scan -L pnpm-lock.yaml
# On GitHub, enable Dependabot (repo Settings → Security)This site itself, per this lesson, monitors its own dependencies for CVEs — we practice what we recommend.
Common mistakes and how to fix them
- Putting a new key on a server that's still open: the same path steals the new key too. Stop the abused key right away, fix the vulnerability, and then finish replacing the remaining secrets.
- Treating a budget alert as a cap: Google Cloud states that an alerts-only budget doesn't automatically stop usage or spending. If you want it stopped, add a limit that blocks usage, such as OpenAI's monthly hard limit or an Anthropic Workspace spend limit.
- Putting an AI API key in a browser or mobile app: any value that reaches the user's device can be pulled out. Call the AI API from your server, and have the browser call your server.
- Deleting it from files or Git history and stopping there: anyone who already copied it can still use it. Revoke first, clean up history later (see why leaked GitHub credentials stay valid).
- Using the same key in development and production: you can't tell which side leaked, so you have to stop both. Issue a separate key per environment.
A public example: environment variables leaked from the provider's side
Leaks that no file search can find also happen outside your own server. On April 19, 2026, Vercel disclosed unauthorized access to its internal systems. According to the company, a compromise of a third-party AI tool used by an employee led to the theft of some customers' environment variables that were not marked "sensitive" (the setting that makes a value unviewable after saving).
Vercel advised customers to review and rotate environment variables not marked sensitive, and to check activity logs and recent deployments. It also stated that deleting projects or the account is not enough to remove the risk.
Customers in that situation could search their code and Git all day and find no trace of a leak. The action is the same as in this case: replace every secret stored in that place. The steps are in the Vercel security incident (April 2026).
Lessons
Common mistakes
- Calling it done at "stopped the charges"
- Relaxing because grep is clean
- Masking the symptom at a proxy and feeling fixed
- Counting vulnerabilities by the
package.jsonfloor - Rotating only the one confirmed-abused key
Correct defense
- Treat stopping the charges and closing the leak path as two separate jobs, and do both
- Suspect runtime leaks (RCE, headers) too
- Update the framework that caused it
- Judge by the version actually running
- Replace the whole env if it leaks
To sum up: the bill spike was a symptom, and the cause was a CVSS 10.0 RCE left unpatched. The correct cause was not found on the first try; it was found by being wrong several times and checking again.
Read next
- After a leak: 543,699 credentials exposed on GitHub are still valid — why you must revoke, not just delete
- Glossary: What is RCE · What is a CVE · What is CVSS
- Defense: Running Next.js safely (CVE hygiene)
- Incident: When .env was exposed to the whole world
- Basics: .env and API key basics (what to do after a leak)
- Prevention: Stop secrets before commit with gitleaks
- Incident: When a stolen API key gets your OpenAI account banned
Sources
- OWASP, "Secrets Management Cheat Sheet" (environment variable risks, response to leaks): cheatsheetseries.owasp.org
- Next.js Docs, "How to use environment variables in Next.js" (inlining of
NEXT_PUBLIC_): nextjs.org - OSV-Scanner, "Usage": google.github.io
- OpenAI, "Production best practices" (key handling, alerts and limits): developers.openai.com
- Claude Help Center, "Creating and managing Workspaces" (Workspace limits and keys): support.claude.com
- Google Cloud, "Create, edit, or delete budgets and budget alerts": docs.cloud.google.com
- Vercel, "Vercel April 2026 security incident": vercel.com