How API Key Leakage in Error Messages Gets You Pwned
Introduction
Most developers know they shouldn't hardcode API keys in source code. Most developers use .env files. Most developers think that's enough.
It isn't.
There's a subtler, far more common attack surface that rarely gets discussed: your API key leaking through error messages at runtime. This is exactly what was happening in gemini-eval.mjs, and it's the kind of vulnerability that can go undetected for months while your credentials quietly flow into log files, CI/CD pipelines, and monitoring dashboards.
This post breaks down the vulnerability, the fix, and the broader lessons every developer working with third-party APIs should internalize.
The Vulnerability Explained
What Was Happening?
The gemini-eval.mjs script integrates with Google's Generative AI API. Like most API integrations, it loads a secret key from an environment variable:
const apiKey = process.env.GEMINI_API_KEY;
This part is fine. The problem was in the error handling block:
// BEFORE — Vulnerable code
} catch (err) {
console.error('❌ Gemini API error:', err.message);
if (err.message?.includes('API_KEY')) {
console.error(' Check your GEMINI_API_KEY in .env');
} else if (err.message?.includes('quota') || err.message?.includes('rate')) {
console.error(' You may have hit the free-tier rate limit. Wait 60s and retry.');
}
process.exit(1);
}
See the issue? err.message is logged raw and unfiltered.
When an API call fails — especially with authentication errors — many API providers include context in the error message to help developers debug. That context can include the key that was used in the request. A response like:
Invalid API key: AIzaSyD3x...your-actual-key...Qf8
...gets printed verbatim to stdout/stderr.
Why This Is Classified as Critical
The severity isn't just about one error message on one developer's laptop. Consider the full blast radius:
1. CI/CD Pipeline Logs
Most teams run scripts like this in automated pipelines. GitHub Actions, GitLab CI, Jenkins, CircleCI — they all capture stdout/stderr. If your pipeline is set to verbose output, or if an API error triggers during a run, that key is now sitting in your build logs. Many of these systems retain logs for 90+ days by default.
2. Log Aggregation Systems
If your team ships logs to Datadog, Splunk, Elastic, or similar platforms, error messages flow there automatically. Now your API key is indexed, searchable, and potentially accessible to anyone with read access to your logging infrastructure.
3. Terminal History and Screen Recordings
Developers running this locally may have terminal sessions recorded, or their shell history synced. A single failed API call during a demo or pair programming session exposes the key.
4. Error Monitoring Tools
Tools like Sentry capture exception messages and their context. An unhandled error containing your API key gets shipped to a third-party service.
5. Shared Screens and Screenshots
It sounds trivial, but "I'll just share my screen to debug this" has leaked more secrets than most people realize.
The Real-World Attack Scenario
Here's how this plays out in practice:
- Developer runs
gemini-eval.mjsin a GitHub Actions workflow - The Gemini API returns a quota error that includes the key in its message body
- The error is logged to the Actions console:
❌ Gemini API error: Quota exceeded for key AIzaSy... - The workflow log is public (common for open-source repos) or accessible to a contractor
- Attacker extracts the key, uses it to make API calls billed to the victim's account
- Victim discovers the breach weeks later via an unexpected Google Cloud invoice
This is not a theoretical attack. API key theft via log scraping is one of the most common credential compromise vectors documented by cloud providers.
The Fix
What Changed
The fix is elegant and minimal. Instead of logging err.message directly, the error message is first sanitized by replacing any occurrence of the actual API key with the string [REDACTED]:
// AFTER — Fixed code
} catch (err) {
const sanitizedMsg = (err.message || '').split(apiKey).join('[REDACTED]');
console.error('❌ Gemini API error:', sanitizedMsg);
if (sanitizedMsg.includes('API_KEY')) {
console.error(' Check your GEMINI_API_KEY in .env');
} else if (sanitizedMsg.includes('quota') || sanitizedMsg.includes('rate')) {
console.error(' You may have hit the free-tier rate limit. Wait 60s and retry.');
}
process.exit(1);
}
How It Works
The redaction pattern (err.message || '').split(apiKey).join('[REDACTED]') is a deliberate choice:
-
.split(apiKey).join('[REDACTED]')— This is a safe string replacement idiom in JavaScript. UnlikeString.prototype.replace()with a string argument (which only replaces the first occurrence), splitting on the key and rejoining replaces all occurrences of the key in the message. -
(err.message || '')— The nullish fallback prevents a crash iferr.messageis undefined, which can happen with certain error types. -
Consistent use of
sanitizedMsg— The fix also updates all subsequent references fromerr.messagetosanitizedMsg, ensuring the sanitized version is used throughout the catch block. This is important: a partial fix that redacts theconsole.errorbut leaveserr.messagein a conditional check would still be safe here, but using the sanitized version consistently is a better habit.
The Second Fix: Regex Injection Prevention
The PR also addresses a second, related issue in the extract function:
// BEFORE — Vulnerable to ReDoS
const extract = (key) => {
const m = block.match(new RegExp(`${key}:\\s*(.+)`));
return m ? m[1].trim() : 'unknown';
};
// AFTER — Safe string parsing
const extract = (key) => {
const prefix = `${key}:`;
const lines = block.split('\n');
for (const line of lines) {
const trimmed = line.trimStart();
if (trimmed.startsWith(prefix)) {
return trimmed.slice(prefix.length).trim();
}
}
return 'unknown';
};
The original code constructed a RegExp from the key parameter. If key contained regex special characters, this could cause unexpected behavior or, in a worst case, a ReDoS (Regular Expression Denial of Service) attack. The replacement uses straightforward string operations — splitting on newlines and checking for a prefix — which is both safer and more readable.
Prevention & Best Practices
1. Always Sanitize Secrets Before Logging
Treat your secret values as toxic to your logging pipeline. Before any string goes to console.log, console.error, a logger, or an error monitoring service, strip known secrets from it.
// A simple utility function
function redactSecrets(message, secrets) {
return secrets.reduce((msg, secret) => {
if (!secret) return msg;
return msg.split(secret).join('[REDACTED]');
}, message);
}
// Usage
const sanitized = redactSecrets(err.message, [apiKey, otherSecret]);
console.error(sanitized);
2. Use Secret Scanning in Your CI/CD Pipeline
Tools like GitHub Secret Scanning, GitGuardian, TruffleHog, and gitleaks can detect when secrets appear in commits or, in some configurations, in build artifacts. Enable them.
# Example: Add TruffleHog to GitHub Actions
- name: TruffleHog OSS
uses: trufflesecurity/trufflehog@main
with:
path: ./
base: ${{ github.event.repository.default_branch }}
head: HEAD
3. Mask Secrets in CI/CD Systems
Most CI/CD platforms support secret masking — if a registered secret value appears in log output, it's automatically replaced with ***. This is a defense-in-depth measure, not a replacement for code-level sanitization.
In GitHub Actions:
env:
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
Values stored as GitHub Secrets are automatically masked in logs. But don't rely on this alone — the masking only works if the platform knows the value, and it can be bypassed by encoding or splitting the secret.
4. Rotate Keys After Any Suspected Exposure
If you're not sure whether a key was exposed, rotate it. The cost of rotation is always lower than the cost of a breach. Most API providers make this a one-click operation in their console.
5. Apply the Principle of Least Privilege to API Keys
Use API keys scoped to the minimum permissions required. A key used for evaluation scripts doesn't need write access to your production data. If a scoped key leaks, the blast radius is contained.
6. Audit Your Error Handling Regularly
Error handling code is often written quickly and reviewed lightly. Make it a habit to audit catch blocks for places where raw error messages, request objects, or response bodies are logged. These are common leakage points.
7. Use a Secrets Manager for Production
For anything beyond local development tooling, use a proper secrets manager:
- AWS Secrets Manager / AWS Parameter Store
- HashiCorp Vault
- Google Secret Manager
- Azure Key Vault
These systems provide audit logs, automatic rotation, and fine-grained access control.
Relevant Standards and References
- OWASP Top 10 A02:2021 — Cryptographic Failures: Covers sensitive data exposure, including credentials in logs
- OWASP Top 10 A05:2021 — Security Misconfiguration: Covers overly verbose error messages
- CWE-532: Insertion of Sensitive Information into Log File: The exact CWE this vulnerability maps to
- CWE-209: Generation of Error Message Containing Sensitive Information: Related to exposing secrets via error output
- NIST SP 800-92: Guide to Computer Security Log Management
Conclusion
This vulnerability is a reminder that secrets don't only leak through obvious channels. You can do everything right — use .env files, never commit credentials, use environment variables in CI — and still expose your API key through a single unguarded console.error call.
The fix here is small: three lines changed. But the thinking behind it matters more than the lines themselves. The developer who wrote the fix internalized a principle: treat your secret values as radioactive. Before any string leaves your process — whether to a log file, a monitoring service, or a terminal — ask yourself: could my secret be in here?
A few key takeaways to carry forward:
- Error messages from external APIs can contain your credentials. Always sanitize before logging.
- CI/CD logs are a primary attack surface for credential theft. Treat them as semi-public.
- Defense in depth applies to secrets. Code-level sanitization + CI masking + secret scanning is better than any one alone.
- Small fixes matter. Three lines of code eliminated a critical exposure vector. Security doesn't always require a massive refactor.
Write the sanitization code. Add the secret scanner. Rotate the keys. The attacker scraping your build logs is counting on you to skip these steps.
This vulnerability was identified and fixed by OrbisAI Security. Automated security scanning catches issues like this before they reach production.