One in Eight: Credentials Hiding in Public MCP Config Files

·10 min read·Evergreen Tools Team

Hush Security published research in September analysing roughly 82,000 public Model Context Protocol configuration files, and the number to remember is one in eight. Twelve percent of credential slots in those files hardcode a secret, which on its own would be a familiar story about developers being careless with API keys. What makes this one worth an hour of your week is the second finding: 55% of those hardcoded secrets have no vendor-recognisable token shape, which means the pattern-matching that gitleaks and GitHub secret scanning rely on does not fire. The files are public, the credentials are live, and the tools that are supposed to catch this are looking for a prefix that is not there.

A code editor showing a configuration file

MCP config files are designed to be committed. That is exactly the problem.

What was actually measured

The dataset is configuration files for MCP servers across Claude Code, Cursor, VS Code, Windsurf, Gemini, OpenAI Codex, JetBrains and other coding agents. That spread matters, because it means this is not one vendor's problem. Hush's framing of the root cause is precise: MCP config files tell an agent which tools to connect to and how to authenticate, and unlike a .env file they are designed to be committed to source control so teams can share agent tooling. Committing the file is the intended behaviour. Committing the secret is not, but the two live in the same file, and the file's whole purpose is to be shared. Hush also notes the protocol itself is not helping: authentication is optional in practice, unenforced for local servers, and silent about what an agent may do once it is connected.

# Variable expansion is the difference between a config file and a credential file.
import json
import os
import re

COMMITTED = {   # this is the version that goes into the repository
    "mcpServers": {
        "github": {
            "command": "npx",
            "args": ["-y", "@modelcontextprotocol/server-github"],
            "env": {"GITHUB_PERSONAL_ACCESS_TOKEN": "${GH_TOKEN}"},
        },
    }
}

def resolve(spec, environ):
    missing = []

    def walk(node):
        if isinstance(node, str):
            def sub(match):
                key = match.group(1)
                if key not in environ:
                    missing.append(key)
                    return ""
                return environ[key]
            return re.sub(r"\$\{([A-Z0-9_]+)\}", sub, node)
        if isinstance(node, dict):
            return {k: walk(v) for k, v in node.items()}
        if isinstance(node, list):
            return [walk(v) for v in node]
        return node

    return {"resolved": walk(spec), "missing": missing, "ok": not missing}

print(resolve(COMMITTED, {"GH_TOKEN": "ghp_" + os.urandom(4).hex()}))
print(resolve(COMMITTED, {}))
print("committed file holds no secret:", "ghp_" not in json.dumps(COMMITTED))

# A missing variable should fail loudly at startup, not silently hand the agent an empty credential.

Why the scanners walk past 55% of it

Secret scanning works on shapes. A GitHub personal access token starts with ghp_, an Anthropic key and an OpenAI key have their own prefixes, and those prefixes are what make a scanner fast and low-noise. The cost of that design is everything without a recognised prefix. Fifty-five percent of the hardcoded secrets in this dataset fall into that gap, which is why the leak class is described as largely invisible. The dataset is not only odd, obscure credentials either: GitHub personal access tokens, Anthropic and OpenAI API keys, Slack and Notion workspace tokens, and database connection strings with embedded passwords are all represented. Some of them have prefixes and some do not. If your secret scanning is entirely prefix-based, you are effectively scanning a subset and calling it a policy.

# The secrets scanners miss have no vendor prefix. Catch them by name, length and entropy.
import math
import re
from collections import Counter

ASSIGNMENT = re.compile(
    r"(?i)\b([A-Z0-9_]*(?:TOKEN|KEY|SECRET|PASSWORD|PASSWD|CREDENTIAL|DSN|CONN)[A-Z0-9_]*)[\"']?"
    r"\s*[:=]\s*[\"']?([^\"'\s,}]+)"
)

def shannon_entropy(value):
    if not value:
        return 0.0
    length = len(value)
    counts = Counter(value)
    return -sum((c / length) * math.log2(c / length) for c in counts.values())

def scan(text, min_entropy=3.2, min_length=16):
    findings = []
    for name, value in ASSIGNMENT.findall(text):
        if value.startswith("${") or value in {"null", "changeme", "REPLACE_ME"}:
            continue
        if len(value) >= min_length and shannon_entropy(value) >= min_entropy:
            findings.append({
                "name": name,
                "length": len(value),
                "entropy": round(shannon_entropy(value), 2),
                "preview": value[:4] + "...",
            })
    return findings

import random
import string

def fake_secret(length, seed=20261015):
    rng = random.Random(seed)
    alphabet = string.ascii_letters + string.digits
    return "".join(rng.choice(alphabet) for _ in range(length))

SAMPLE = (
    '"env": {\n'
    '  "GITHUB_PERSONAL_ACCESS_TOKEN": "${GH_TOKEN}",\n'
    '  "DB_PASSWORD": "correct-horse-battery-staple",\n'
    '  "SESSION_SECRET": "' + fake_secret(24) + '",\n'
    '  "CHAT_BOT_TOKEN": "' + fake_secret(20) + '"\n'
    '}\n'
)

for finding in scan(SAMPLE):
    print(finding)

# Prefix matching catches ghp_ and sk-. Entropy catches the ones nobody gave a prefix to.
Dark screen with command line output

Most scanners match prefixes. The dangerous secrets are the ones with no prefix.

Scope and expiry are where the damage multiplies

A leaked read-only token for a sandbox is bad. A leaked workspace-wide token that never expires is a different category, and the dataset skews hard toward the second. Among leaked credentials where a scope could be determined, 53% were organisation, account, workspace or database wide. Among those with a defined expiry policy, 80% never expire by default, which is a property of how these tokens are issued rather than a decision anyone made. Put those two together and 24% of all hardcoded secrets in the dataset were both broad-scope and non-expiring. Micha Rave, Hush's CEO, makes the point that the instinct security teams have been trained on, which is scan, block the commit, rotate what leaked, is not enough here, because the identity behind the token has no owner and no expiry.

# Deleting the line does not delete the secret. Audit history, not just the current tree.
import subprocess

def history_hits(path, needle="ghp_"):
    commits = subprocess.run(
        ["git", "log", "--format=%H", "--", path],
        capture_output=True, text=True,
    )
    if commits.returncode != 0:
        return {"error": "not a git repository, or path is untracked"}
    hits = []
    for sha in commits.stdout.split():
        blob = subprocess.run(["git", "show", sha + ":" + path], capture_output=True, text=True)
        if blob.returncode == 0 and needle in blob.stdout:
            hits.append({"commit": sha[:8], "readable_in_history": True})
    return {"path": path, "commits_with_secret": hits}

print(history_hits("mcp.json"))
print(history_hits("does-not-exist.json"))

# In Hush's dataset, 7,681 credential-bearing configs were traced through history.
# 1,394 secrets were still live in the current file; 243 more were readable only in old commits.

Deleting the line is not remediation

The history finding is the one that should change how you close these tickets. Hush traced Git history across 7,681 credential-bearing config files and found 1,394 secrets still live in the current file, plus 243 more that had been removed from the current file but remain fully readable in earlier commits. A removed line is not a rotated credential, it is a credential with a shorter README. This is a well-known property of Git and it still gets missed constantly, because the fix feels like editing a file and closing a pull request. If the value was ever committed, the only remediation is rotation, and if the repository is public, assume it was collected. Hush's recommendation is blunt: rotate any secret that was ever committed.

# Give every agent identity an owner and an expiry. The default is neither.
from datetime import date

IDENTITIES = [
    {"name": "github-pat-ci", "owner": "platform-team", "expires": date(2026, 12, 31), "scope": "repo"},
    {"name": "notion-workspace", "owner": None, "expires": None, "scope": "workspace"},
    {"name": "openai-key-dev", "owner": "eric", "expires": date(2026, 11, 30), "scope": "account"},
]
BROAD_SCOPES = {"organization", "account", "workspace", "database"}

def audit(identities, today=date(2026, 10, 15), warn_days=45):
    problems = []
    for ident in identities:
        if not ident["owner"]:
            problems.append((ident["name"], "no owner"))
        if not ident["expires"]:
            problems.append((ident["name"], "never expires"))
        elif (ident["expires"] - today).days <= warn_days:
            problems.append((ident["name"], "expires in " + str((ident["expires"] - today).days) + " days"))
        if ident["scope"] in BROAD_SCOPES:
            problems.append((ident["name"], "broad scope: " + ident["scope"]))
    return problems

for row in audit(IDENTITIES):
    print(row)

# 24% of the hardcoded secrets in Hush's dataset were both broad-scope and non-expiring. Start there.
A padlock resting on a keyboard

Deleting the line does not delete the secret. Only rotation does.

The four fixes, and what they cost you

Hush recommends four things and none of them require a new platform. Never commit an inline secret, and use variable expansion wherever the config format allows it. Move off static, long-lived credentials toward short-lived, identity-based access, and rotate anything that was ever committed. Keep credentials out of the agent and out of the MCP config entirely, brokering them from a secure location so there is nothing at the edge to steal. And give every agent identity an explicit owner and an expiry, because an identity with neither is invisible by construction. The first and fourth are configuration and process changes you can do this week. The third is the one that actually shrinks the attack surface, because it removes the value rather than moving it.

# Keep the credential out of the agent and out of the config. Broker it when it is needed.
import time

class FakeVault:
    def __init__(self):
        self.calls = 0

    def issue(self, identity, ttl_seconds):
        self.calls += 1
        return "short-lived-token-" + str(self.calls)

class SecretBroker:
    def __init__(self, vault, ttl_seconds=900):
        self.vault = vault
        self.ttl = ttl_seconds
        self.cache = {}

    def get(self, identity, now=None):
        now = now if now is not None else time.time()
        entry = self.cache.get(identity)
        if entry and entry["expires_at"] > now:
            return {"identity": identity, "secret": entry["secret"], "from_cache": True}
        secret = self.vault.issue(identity, ttl_seconds=self.ttl)
        self.cache[identity] = {"secret": secret, "expires_at": now + self.ttl}
        return {"identity": identity, "secret": secret, "from_cache": False}

broker = SecretBroker(FakeVault())
print(broker.get("github-pat-ci", now=1000))
print(broker.get("github-pat-ci", now=1300))
print(broker.get("github-pat-ci", now=2500))

# Nothing in the config file is worth stealing, because the value is not in the config file.

A sensible order of operations

Start by finding out whether you are exposed, and do it on the history rather than the working tree, because that is where the readable copies are. Then triage by blast radius instead of by file: a workspace-wide token that never expires outranks a read-only token for a test environment, even if the second one looks scarier in a scan report. Then convert configs to variable expansion so the next commit is clean, and add an entropy-based check alongside whatever prefix matching you already run, since the prefix-based half is exactly the half that is already covered. Rotation is the only step that actually closes anything, so schedule it rather than treating it as the end of the ticket.

📌 Frequently Asked Questions

What did Hush Security find?

An analysis of roughly 82,000 public MCP configuration files found that 12% of credential slots hardcode a secret, that 55% of those secrets have no vendor-recognisable token shape, and that the highest-risk credentials are broad in scope and rarely expire.

Why do secret scanners miss these credentials?

Tools such as gitleaks and GitHub secret scanning largely match vendor-recognisable token shapes and prefixes. Hush found that 55% of the hardcoded secrets in the dataset have no such shape, so there is no pattern for those scanners to match on.

Which credentials were in the dataset?

GitHub personal access tokens, Anthropic and OpenAI API keys, Slack and Notion workspace tokens, and database connection strings with embedded passwords, drawn from configs for Claude Code, Cursor, VS Code, Windsurf, Gemini, OpenAI Codex, JetBrains and other agents.

Does removing the secret from the file fix it?

No. Hush traced history across 7,681 credential-bearing configs and found 243 secrets that had been removed from the current file were still fully readable in earlier commits. If a secret was ever committed, rotation is the only real remediation.

What are Hush's recommendations?

Never commit an inline secret and use variable expansion; move to short-lived identity-based access and rotate anything ever committed; broker credentials from a secure location so nothing sits in the agent or the MCP config; and give every agent identity an explicit owner and an expiry.