Back to Blog
critical SEVERITY8 min read

How API Key Leakage in Error Messages Gets You Pwned

A critical vulnerability in `gemini-eval.mjs` allowed a live API key to be printed directly into error logs and console output whenever the Gemini API returned an error message containing the key string. The fix introduces runtime redaction of the API key from all error messages before they are logged, preventing accidental exposure in CI/CD pipelines, terminal history, and log aggregation systems. This is a textbook example of how secrets can leak through indirect channels even when developers

O
By Orbis AppSec
•Technically reviewed by Anupam Mediratta•Published May 28, 2026•Reviewed June 3, 2026

Answer Summary

API key leakage in error messages (CWE-532: Insertion of Sensitive Information into Log File) occurs when error handling code in Node.js logs exceptions or API responses that contain embedded secrets. In `gemini-eval.mjs`, the Gemini API returned error messages that included the API key string, which was then printed directly to console output and logs. The fix implements runtime string replacement to redact the API key from all error messages before they reach logging systems, using `errorMessage.replace(apiKey, '[REDACTED]')` to sanitize output.

Vulnerability at a Glance

cweCWE-532 (Insertion of Sensitive Information into Log File)
fixRuntime redaction of API key from all error messages before logging
riskLive API keys exposed in logs, CI/CD output, and terminal history
languageNode.js / JavaScript
root causeError messages from Gemini API contained the API key string, logged without sanitization
vulnerabilityAPI key leakage through error message logging

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:

  1. Developer runs gemini-eval.mjs in a GitHub Actions workflow
  2. The Gemini API returns a quota error that includes the key in its message body
  3. The error is logged to the Actions console: ❌ Gemini API error: Quota exceeded for key AIzaSy...
  4. The workflow log is public (common for open-source repos) or accessible to a contractor
  5. Attacker extracts the key, uses it to make API calls billed to the victim's account
  6. 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. Unlike String.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 if err.message is undefined, which can happen with certain error types.

  • Consistent use of sanitizedMsg — The fix also updates all subsequent references from err.message to sanitizedMsg, ensuring the sanitized version is used throughout the catch block. This is important: a partial fix that redacts the console.error but leaves err.message in 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.


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.

Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #582

Related Articles

medium

path-to-regexp 0.1.12 DoS: Catastrophic Backtracking on Malformed URL

path-to-regexp version 0.1.12 contains a regular expression engine vulnerability where maliciously crafted URL parameters trigger exponential backtracking, causing CPU exhaustion and service unavailability. Upgrading to version 0.1.13 eliminates the vulnerable pattern from the parsing logic.

critical

CVE-2026-59873: node-tar 7.5.11 DoS via Crafted Gzip Bomb

node-tar versions 7.5.11 through 7.5.18 are vulnerable to a denial-of-service attack through maliciously crafted gzip archives that decompress to disproportionately large sizes. An attacker can exploit this to exhaust memory and CPU resources by submitting a small, highly compressed archive that expands beyond configured limits during extraction.

high

MapManager.get() Race Condition Duplicates API Requests

The MapManager's `get(mapUid, cache)` method used a check-then-act pattern that permitted multiple concurrent requests to pass the cache miss check simultaneously, triggering redundant API calls and risking cache corruption. The fix introduces a `_pending` promise map to deduplicate in-flight fetches for identical map UIDs.

critical

Lampa Desktop Auto-Update Heuristic Bypass: Execution of Unverified

Lampa Desktop's auto-update mechanism downloaded JavaScript and CSS from `raw.githubusercontent.com` using only heuristic validation—file size thresholds and string pattern matching—that attackers could trivially satisfy. The fix introduces cryptographic integrity verification by cross-referencing Git blob hashes from the GitHub Contents API, ensuring downloaded code matches the repository's authoritative state before execution.

high

adm-zip 0.6.0 Preserves SUID Bits From ZIPs: CVE-2026-102282

The `adm-zip` dependency resolved to 0.6.0 in this project's dependency tree, a version affected by CVE-2026-102282: during extraction it applies the Unix permission bits stored in each ZIP entry's external file attributes verbatim, including the setuid (`04000`), setgid (`02000`), and sticky bits. An attacker who controls an archive passed to `extractAllTo()` or `extractEntryTo()` can therefore have the extractor create a setuid binary owned by whatever user the extraction process runs as. The

high

requestInput() Type Confusion: NaN and Object Bypass in JavaScript

The `requestInput()` utility function lacked validation on its `type` parameter and failed to handle `NaN` results from float conversions, creating a type confusion weakness. An attacker could supply malformed inputs that propagate unhandled `NaN` values or unexpected object types through the type system. The fix adds explicit guards against `NaN` type parameters and rejects non-primitive type values.