I opened settings.json to clean up some MCP server config. What I found was 37 credentials stored in plaintext.
The Setup
11:40 AM, routine config cleanup. I was fixing a misconfigured MCP server entry — wrong default model, missing command args. Standard housekeeping. While I was in there, I scrolled through the permissions array.
The permissions array is where Claude Code stores approved bash commands. When you approve a command, the tool saves the full command text so it won’t ask again next time. Useful feature. Except “full command text” means full command text — including any credentials you passed inline.
What I thought settings.json stored: Tool permission patterns like “allow bash commands matching npm test.”
What it actually stored: Every approved bash command, verbatim, including API keys, passwords, and tokens passed as environment variables.
The Inventory
I went through every permission entry. 37 of them contained hardcoded credentials.
| Category | What Was Exposed | Rotation Urgency |
|---|---|---|
| Cloud API keys | 2 services | Immediate — remote access |
| Automation platform | API key | Immediate — triggers workflows |
| CMS app passwords | 2 sites | Immediate — public-facing |
| Package registry tokens | 2 tokens | Immediate — publish access |
| Text-to-speech API | API key | Immediate — paid service |
| DNS filtering | Admin password | Immediate — admin access |
| Media management stack | 8 services | Lower — local network only |
| SSH password | Server access | Check if still active |
Six categories of credentials needed immediate rotation — cloud services, admin panels, anything internet-accessible. The media management stack is local-only, less urgent, but still credentials in a plaintext file that syncs between machines.
The Mechanism
Here’s the specific pattern that creates this:
# What you type (BAD):
SOME_API_KEY="sk-live-abc123" node deploy.js
# What settings.json permanently stores:
# "SOME_API_KEY=\"sk-live-abc123\" node deploy.js"
# What you should type instead (GOOD):
node deploy.js
# (with SOME_API_KEY already exported in your shell)
The first pattern embeds the credential in the command string. Claude Code saves the command string. The credential is now in a JSON file on disk, permanently, syncing to every machine with your dotfiles.
The second pattern references an environment variable that’s already set. The command string contains no credential. Nothing leaks.
The Fix
Three steps, in order:
- Backup —
settings.json.backup-20260126-*(because deleting 37 entries from a config file without a backup is its own kind of credential event) - Remove — Deleted all 37 permission entries containing credentials
- Prevent — Added a “Permissions Array Leakage” section to the global instructions file:
Before running commands with credentials:
- Export the credential in your shell FIRST
- Then run the command WITHOUT the inline credential
- Or use a secrets manager CLI
The rotation itself is still pending. That’s a separate session.
The Pattern
The tool that stores your permissions is also the tool that stores your secrets, if you let credentials touch command strings. It’s not a bug — it’s doing exactly what it’s supposed to do. The command was approved, so the command was saved. The problem is that “the command” included things that should never be saved anywhere.
Why this is easy to miss
It works perfectly during the session. The command runs, the credential authenticates, the operation succeeds. There’s no error, no warning, no indication that something was persisted. The feedback loop — “it worked, move on” — actively discourages checking what happened under the hood.
The 37 entries accumulated over weeks. Each one was a single approved command that worked correctly. The accumulation was invisible until I happened to scroll through the permissions array for an unrelated reason.
The Damage Report
| Metric | Value |
|---|---|
| Credentials found in plaintext | 37 |
| Credential categories needing immediate rotation | 6 |
| Local-only services (lower urgency) | 8 |
| Time to discover | Weeks (found by accident) |
| Time to remove entries | ~20 minutes |
| Time to rotate all credentials | TBD |
| Prevention rule added | Yes — 3 lines in global config |
The three-line prevention rule is doing more work than the 20-minute cleanup. The cleanup fixed the past. The rule fixes the future. But neither would exist if I hadn’t been in that file for a completely different reason.