Back to Blog
critical SEVERITY11 min read

How credential leakage through console logging happens in JavaScript browser extensions and how to fix it

A browser extension's `src/background/credentials.js` printed the full Strava authentication cookie string — including a signed JWT and CloudFront-Signature values — straight into the extension console via `console.debug`. Anyone who could open DevTools on the background page (or any tooling that scraped the console) could copy a live session and impersonate the user. The fix replaces the credential payload in both log statements with `Boolean(credentials)` and strips a realistic-looking JWT out

O
By Orbis AppSec
Published September 9, 2026Reviewed September 9, 2026

Answer Summary

This is a sensitive-information-in-logs vulnerability (CWE-532) in the JavaScript background script of a browser extension: `requestCredentials()` in `src/background/credentials.js` passed the raw Strava cookie string — `_strava_idcf` JWT, `CloudFront-Policy`, `CloudFront-Signature`, `CloudFront-Key-Pair-Id` — to `console.debug`, persisting live session credentials into the extension's console buffer. The fix logs only presence, not

Vulnerability at a Glance

cweCWE-532 (Insertion of Sensitive Information into Log File), related CWE-312, CWE-798
fixLog `Boolean(credentials)` instead of the credential value, and replace the realistic JWT in `expireCredentials()` with a clearly non-credential placeholder
riskA live Strava session cookie set (JWT + CloudFront signed-cookie triple) is written to the extension console, enabling full account/session takeover by anyone who can read that console
languageJavaScript (WebExtension / MV3 background service worker)
root cause`console.debug` in `requestCredentials()` received the entire credential string returned by `fetchCookies(STRAVA_COOKIE_URL, STRAVA_COOKIE_NAMES)` instead of a non-sensitive summary
vulnerabilitySensitive credential exposure through debug logging (plus a secret-shaped hardcoded fixture)

console.debug('[StravaHeatmapExt] Credentials fetched:', Boolean(credentials)), and the hardcoded JWT-shaped fixture in expireCredentials() was replaced with the obviously-fake string EXPIRED-NOT-A-REAL-TOKEN. The general rule is to log identifiers and booleans, never secret material, and to keep secret-shaped strings out of source entirely.

VULNERABILITY_AT_A_GLANCE:
Vulnerability: Sensitive credential exposure through debug logging (plus a secret-shaped hardcoded fixture)
CWE: CWE-532 (Insertion of Sensitive Information into Log File), related CWE-312, CWE-798
Language: JavaScript (WebExtension / MV3 background service worker)
Risk: A live Strava session cookie set (JWT + CloudFront signed-cookie triple) is written to the extension console, enabling full account/session takeover by anyone who can read that console
Root cause: console.debug in requestCredentials() received the entire credential string returned by fetchCookies(STRAVA_COOKIE_URL, STRAVA_COOKIE_NAMES) instead of a non-sensitive summary
Fix: Log Boolean(credentials) instead of the credential value, and replace the realistic JWT in expireCredentials() with a clearly non-credential placeholder

FAQ:
Q: What is credential leakage through console logging?
A: It is a class of sensitive data exposure where secrets — tokens, cookies, API keys, passwords — are passed to a logging function such as console.debug, console.log, or a server-side logger. The secret is then persisted somewhere that has weaker access controls than the credential store it came from: a DevTools console buffer, a log file, a log aggregation service, or a crash report. It is tracked as CWE-532.

Q: How do you prevent credential leakage through console logging in JavaScript?
A: Never pass a secret-bearing value directly to a logger. Log presence or shape instead: Boolean(token), token.length, a hash prefix, or an opaque identifier. Wrap credential objects in a class with a toJSON()/Symbol.for('nodejs.util.inspect.custom') that returns '[REDACTED]', keep a central redact() helper, and add a lint rule or CI grep that fails when known secret variable names appear inside console.* calls.

Q: What CWE is credential leakage through console logging?
A: CWE-532, "Insertion of Sensitive Information into Log File." Because the credentials also sit unencrypted in browser.storage.local, CWE-312 ("Cleartext Storage of Sensitive Information") applies, and the JWT-shaped literal that was removed from expireCredentials() touches CWE-798 ("Use of Hard-coded Credentials").

Q: Is stripping logs in production builds enough to prevent this?
A: No. Build-time log stripping helps but fails in several common cases: debug builds shipped to beta channels, extensions loaded unpacked during development on machines that also hold real sessions, console.debug calls behind dynamic flags that the stripper cannot statically remove, and log statements added later by contributors who assume the stripper handles everything. The safe default is to make the logged value itself non-sensitive, as this fix does.

Q: Can static analysis detect credential leakage through console logging?
A: Yes, quite reliably. Taint analysis can trace a value from a credential source — here fetchCookies(...) and browser.storage.local.get('credentials') — to a logging sink like console.debug, and pattern rules can flag any console.* call whose arguments include identifiers matching credential, token, cookie, secret, or apiKey. Entropy and format heuristics also catch JWT-shaped string literals such as the eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9... fixture that was removed.

TAGS: hardcoded-secrets, javascript, browser-extension, logging, jwt, cwe-532

CONTENT:

Answer Summary

This is a sensitive-information-in-logs vulnerability (CWE-532) in the JavaScript background script of a browser extension: requestCredentials() in src/background/credentials.js passed the raw Strava cookie string — _strava_idcf JWT, CloudFront-Policy, CloudFront-Signature, CloudFront-Key-Pair-Id — to console.debug, persisting live session credentials into the extension's console buffer. The fix logs only presence, not content: console.debug('[StravaHeatmapExt] Credentials fetched:', Boolean(credentials)), and the hardcoded JWT-shaped fixture in expireCredentials() was replaced with the obviously-fake string EXPIRED-NOT-A-REAL-TOKEN. The general rule is to log identifiers and booleans, never secret material, and to keep secret-shaped strings out of source entirely.


Vulnerability at a Glance

Field Value
ID V-001
Severity Critical / High
File src/background/credentials.js:35
Type Sensitive credential exposure via debug logging
CWE CWE-532, CWE-312, CWE-798
Language JavaScript (WebExtension background script)
Sink console.debug
Fix Log Boolean(credentials) instead of the credential string

Introduction

The src/background/credentials.js file in this Strava heatmap browser extension has one job: pull the Strava authentication cookies out of the browser, cache them in browser.storage.local, and hand them to the tile-fetching code so that private heatmap tiles render. That is a legitimately sensitive job — the credential blob it handles is the complete set of cookies that Strava's CDN uses to authorize a session:

  • _strava_idcf — a signed JWT containing athleteId, iat, and exp
  • CloudFront-Policy — a base64 policy naming the allowed resource and expiry
  • CloudFront-Signature — the signature over that policy
  • CloudFront-Key-Pair-Id — the key pair that signed it

Together, those four values are the session. Present them to *.strava.com and the CDN treats you as the athlete.

The flaw was not in how the extension fetched or used those values. It was in how it talked about them. Two console.debug statements inside requestCredentials() printed the entire credential string verbatim, and a third problem sat in expireCredentials(), where a fully-formed, realistic JWT had been hardcoded as a test fixture. This post walks through exactly what leaked, how it could be abused, and the minimal change that closed it.


The Vulnerability Explained

The vulnerable code

Here is the relevant slice of requestCredentials() before the fix:

export async function requestCredentials(skipValidation = false) {
  let credentials = await fetchCookies(STRAVA_COOKIE_URL, STRAVA_COOKIE_NAMES);
  console.debug('[StravaHeatmapExt] Credentials fetched:', credentials);   // ← leak #1

  const { credentials: storedCredentials } = await browser.storage.local.get(
    'credentials'
  );

  // ...

  // Update local storage only if credentials changed
  if (credentials !== storedCredentials) {
    await browser.storage.local.set({ credentials });
    console.debug('[StravaHeatmapExt] Stored credentials updated', credentials);  // ← leak #2
  }

fetchCookies(STRAVA_COOKIE_URL, STRAVA_COOKIE_NAMES) returns a serialized cookie string. That string goes straight into console.debug as the second argument. In a WebExtension background service worker, that output lands in the extension's own DevTools console — reachable from chrome://extensionsInspect views: service worker, or about:debugging on Firefox — and it stays in the console's ring buffer for the lifetime of that inspection session.

The second leak is arguably worse: it fires on every credential change, meaning the console accumulates a history of freshly-minted, maximally-valid session tokens over time.

Why "you need DevTools access" is not a defense

The obvious objection is that reading an extension's background console requires local access to the machine. Three reasons that objection is thin:

  1. The threat model in the original finding is real. An attacker with brief physical access to an unlocked machine — a shared desk, a conference room, a laptop left open at a café — presses F12, opens the extension's service worker console, and copies a live token set. No malware, no privilege escalation, no persistence needed. They walk away with an offline-usable credential.
  2. Console output is not as private as it looks. Anything that reads the console reads the secret. Crash/telemetry SDKs that hook console.*, screen-share and pair-programming sessions, screenshots pasted into bug reports, and "here's my console output" GitHub issues have all leaked production secrets before. A developer debugging a tile-loading bug is precisely the person likely to paste this console into a public issue tracker.
  3. The credential outlives the moment. Unlike a value in a variable, a logged value is persisted. It survives the function returning, the page navigating, and in many environments it is flushed to disk.

Concrete attack scenario

  1. Alice installs the extension. It calls requestCredentials(), which fetches her _strava_idcf JWT and CloudFront signed-cookie triple and logs the full string.
  2. Alice hits a rendering bug. She opens chrome://extensions, inspects the service worker, sees [StravaHeatmapExt] Credentials fetched: _strava_idcf=eyJ0eXAi..., and pastes the console output into a GitHub issue to be helpful.
  3. Bob reads the issue. He copies the four cookie values into curl or a browser cookie editor scoped to *.strava.com.
  4. Until exp in the JWT and DateLessThan in the CloudFront policy pass, Bob can fetch Alice's private heatmap tiles and any other CDN resource that policy covers — revealing her home address, commute route, and training patterns from the tile coordinates alone.

For a fitness app, GPS-derived location history is among the most sensitive data a user has. That is why a "just a debug log" finding is rated critical here.

The second problem: a JWT-shaped literal in source

expireCredentials() — a helper that deliberately installs stale cookies so the extension's re-auth path can be exercised — contained this:

const expiredCredentials =
  '_strava_idcf=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE2MDAwMDAwMDAsImlhdCI6MTYwMDAwMDAwMCwiYXRobGV0ZUlkIjo5OTk5OTk5OSwidGltZXN0YW1wIjoxNjAwMDAwMDAwfQ.invalid; CloudFront-Key-Pair-Id=INVALID; CloudFront-Policy=eyJTdGF0ZW1lbnQiOiBbeyJSZXNvdXJjZSI6Imh0dHBzOi8vKi5zdHJhdmEuY29tLyoiLCJDb25kaXRpb24iOnsiRGF0ZUxlc3NUaGFuIjp7IkFXUzpFcG9jaFRpbWUiOjE2MDAwMDAwMDB9fX1dfQ==; CloudFront-Signature=InvalidSignature';

That JWT is not a real credential — the signature is literally the word invalid and the expiry is 2020 — but it is shaped exactly like one. That has real costs:

  • It defeats secret scanning. Every JWT-format detector fires on it. Teams that get one false positive here learn to ignore the detector, and the next alert — a real one — gets ignored too.
  • It teaches the wrong pattern. The next contributor who needs a fixture copies this style, and now there is a plausible chance the copied value is derived from a real capture.
  • It is not self-documenting. Nothing about eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9... tells a reviewer "this is a deliberately fake test value." You have to base64-decode it to find out.

The Fix

The patch is deliberately surgical: three lines, no behavioral change to the auth flow.

Change 1 & 2 — log presence, not content

Before:

let credentials = await fetchCookies(STRAVA_COOKIE_URL, STRAVA_COOKIE_NAMES);
console.debug('[StravaHeatmapExt] Credentials fetched:', credentials);

After:

let credentials = await fetchCookies(STRAVA_COOKIE_URL, STRAVA_COOKIE_NAMES);
console.debug('[StravaHeatmapExt] Credentials fetched:', Boolean(credentials));

Before:

if (credentials !== storedCredentials) {
  await browser.storage.local.set({ credentials });
  console.debug('[StravaHeatmapExt] Stored credentials updated', credentials);
}

After:

if (credentials !== storedCredentials) {
  await browser.storage.local.set({ credentials });
  console.debug('[StravaHeatmapExt] Stored credentials updated', Boolean(credentials));
}

Boolean(credentials) collapses the entire cookie string to true or false. This is the key insight about the fix: the debug statements retained 100% of their diagnostic value. What a developer actually needs to know from Credentials fetched: is "did the cookie fetch succeed or come back empty?" — a yes/no question. The token bytes were never the useful part of the log line; they were incidental payload that happened to be in scope.

Note also that the second log line's condition (credentials !== storedCredentials) already tells you the credentials changed. The value added nothing there either.

Change 3 — de-fang the test fixture

Before:

const expiredCredentials =
  '_strava_idcf=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE2MDAwMDAwMDAsImlhdCI6MTYwMDAwMDAwMCwiYXRobGV0ZUlkIjo5OTk5OTk5OSwidGltZXN0YW1wIjoxNjAwMDAwMDAwfQ.invalid; CloudFront-Key-Pair-Id=INVALID; CloudFront-Policy=eyJTdGF0ZW1lbnQiOiBbeyJSZXNvdXJjZSI6Imh0dHBzOi8vKi5zdHJhdmEuY29tLyoiLCJDb25kaXRpb24iOnsiRGF0ZUxlc3NUaGFuIjp7IkFXUzpFcG9jaFRpbWUiOjE2MDAwMDAwMDB9fX1dfQ==; CloudFront-Signature=InvalidSignature';

After:

const expiredCredentials =
  '_strava_idcf=EXPIRED-NOT-A-REAL-TOKEN; CloudFront-Key-Pair-Id=INVALID; CloudFront-Policy=EXPIRED-NOT-A-REAL-POLICY; CloudFront-Signature=InvalidSignature';

The cookie names are preserved, so clearCookies(STRAVA_COOKIE_URL, STRAVA_COOKIE_NAMES) and the downstream browser.storage.local.set still exercise the same code path — the function's purpose (make the extension think it has stale credentials) is unaffected. What changed is that the values now announce themselves as fake. EXPIRED-NOT-A-REAL-TOKEN is unambiguous to a human reviewer and inert to a secret scanner.

What the fix does not address

Worth stating plainly: the credentials still live unencrypted in browser.storage.local, which is CWE-312. In a browser extension that is a genuinely hard problem — storage.local has no OS keychain binding, and any key you'd derive to encrypt it would have to be stored... in storage.local. The realistic mitigations are: minimize retention (delete the cached copy as soon as the tile request completes), rely on the CloudFront policy's short DateLessThan expiry so a stolen blob has a narrow window, and never widen exposure beyond storage.local — which is exactly what removing the console logs accomplishes. This PR closed the avoidable exposure surface.


Key Takeaways

  • **console.debug('...', credentials) in requestCredentials() wrote a complete Str

Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #40

Related Articles

critical

How Hardcoded Secrets Compromise Authentication in JavaScript and How to Fix It

A critical vulnerability in `Tool/QuantumultX/Rewrite/RRSP.js` exposed hardcoded API authentication credentials—a TOKEN and UMID device identifier—directly in source code. Anyone with repository access could extract these credentials to impersonate the legitimate user and gain full account access to the RRTV API service. The fix replaced hardcoded secrets with empty placeholders, forcing users to manually configure credentials through secure channels.

critical

How API Key Exposure in Request Bodies happens in React and how to fix it

The Chatbot component in gitforme was transmitting Azure OpenAI API keys inside JSON request bodies, causing them to be logged by servers, proxies, and middleware. By moving the apiKey from requestBody.apiKey to an Authorization header, credentials are now protected from persistence in generic request logging infrastructure.

critical

How Hardcoded API Keys in WASM Modules Happen in KAP and How to Fix Them

A critical security vulnerability in `wasm/kap/standard-lib/fhelp-impl.kap` exposed hardcoded Gemini API keys directly in source code distributed to end users via WASM modules. The fix replaces the embedded credential with secure environment variable retrieval, preventing credential extraction through browser developer tools or binary inspection.

critical

How Hardcoded API Key Exposure Happens in Node.js and How to Fix It

A critical vulnerability in `archive/open_claude_code/src/api/client.mjs` exposed five different API providers' credentials through direct `process.env` access. The fix introduced a centralized `readApiKey()` function to enforce secure credential retrieval across Anthropic, OpenAI, Google, and other integrations.

high

How Insufficiently Protected Credentials happens in Node.js and how to fix it

A regex-based guard in `skills/xmemo/scripts/xmemo-skill.mjs` was supposed to block sensitive credentials from being passed as CLI flags, but it only matched a handful of keyword patterns—missing `password`, `client-secret`, `access-token`, `xmemo-key`, and more. The fix expands the blocklist regex to cover these additional credential patterns, closing a gap that could let secrets end up in shell history and process listings.

critical

How stored XSS happens in TinyMCE plugins and how to fix it

The snippets plugin's `Main.ts` inserted raw, unsanitized snippet content directly into the TinyMCE editor via `editor.insertContent(snippet.content)`, allowing stored JavaScript payloads saved by any snippet editor to execute in every user's browser. The fix routes snippet content through TinyMCE's own parser and serializer before insertion, stripping dangerous markup while preserving legitimate formatting.