Using macOS Keychain to Secure MCP Secrets in Claude Code
If you're using Claude Code with MCP servers, you've probably got API keys and database credentials sitting in plaintext config files. I certainly did — production database passwords, error tracking tokens, API keys for our support platform — all just sitting in JSON files on disk.
The good news? Your Mac actually has a solution for this problem built in, and you probably already use it every day. Every time Safari autofills a password or your Mac remembers your Wi-Fi credentials, that's macOS Keychain doing its thing. It turns out we can use exactly the same system to keep our MCP secrets encrypted at rest.
The Problem
A typical MCP configuration looks something like this:
{
"mcpServers": {
"my-database": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-postgres",
"postgres://user:password@host:5432/db"
]
}
}
}
That connection string — password and all — is right there in the file. If it's in your project's .mcp.json then one careless git add . and it's in your commit history forever. Even if it's in ~/.claude.json for global servers, it's still plaintext on disk.
I was setting up a new Mac recently and migrating all my MCP servers across. It seemed like a good time to fix this rather than just copy the problem to a new machine.
The Fix
The security command gives you programmatic access to macOS Keychain from the terminal. The idea is simple: store the secret in Keychain, then use a shell wrapper in the MCP config to retrieve it at runtime.
Storing a Secret
I use mcp-secrets as the service name to keep everything grouped together:
security add-generic-password \
-a "my-database" \
-s "mcp-secrets" \
-w "postgres://user:password@host:5432/db?sslmode=require" \
-U
The -a flag is the account name (I just use the MCP server name), -s is the service, -w is the secret, and -U means update if it already exists.
You can check it worked:
security find-generic-password -a "my-database" -s "mcp-secrets" -w
That should print your secret back.
You might be wondering why this doesn't need sudo or a password each time. Keychain items created by your user are stored in your login keychain, which gets unlocked automatically when you log into your Mac. The first time a new process accesses an entry you might get a dialog asking you to authorize it — click "Always Allow" and it's seamless from then on.
Using it in MCP Config
Instead of calling the MCP server command directly, we wrap it in /bin/sh -c and use command substitution to pull the secret from Keychain at startup.
For secrets in the args (like a Postgres connection string):
{
"mcpServers": {
"my-database": {
"command": "/bin/sh",
"args": [
"-c",
"npx -y @modelcontextprotocol/server-postgres \"$(security find-generic-password -a my-database -s mcp-secrets -w)\""
]
}
}
}
For secrets as environment variables (like API tokens):
{
"mcpServers": {
"my-api-server": {
"command": "/bin/sh",
"args": [
"-c",
"API_TOKEN=$(security find-generic-password -a my-api-server -s mcp-secrets -w) exec npx -y some-mcp-server"
]
}
}
}
The exec there replaces the shell process with the actual server so you don't end up with a dangling parent shell hanging around.
Mixing Keychain secrets with normal env vars:
Sometimes a server needs several environment variables but only one is actually sensitive. You can set the non-sensitive ones inline and just pull the secret from Keychain:
{
"mcpServers": {
"my-server": {
"command": "/bin/sh",
"args": [
"-c",
"SUBDOMAIN=mycompany EMAIL=me@example.com API_KEY=$(security find-generic-password -a my-server -s mcp-secrets -w) exec uv --directory /path/to/server run serve"
]
}
}
}
What's Worth Protecting?
Not everything needs this treatment. Here's roughly how I think about it:
- Definitely Keychain: Production database credentials, API tokens that can access customer data — things like support platforms, error tracking, log aggregators
- Probably fine in config: Local development database passwords, staging credentials for throwaway environments, OAuth client IDs (useless on their own)
- gitignore at minimum: Make sure
.mcp.jsonis in your.gitignoreregardless
The goal isn't to keychain absolutely everything — it's to make sure that the secrets which could actually cause damage if leaked aren't just sitting in a JSON file.
Bonus: AWS Credentials
While I was at it, I realised the same approach works nicely for AWS credentials too. You can store the full credential JSON as a single Keychain entry per profile:
security add-generic-password -a "aws-default" -s "aws-credentials" \
-w '{"Version":1,"AccessKeyId":"AKIA...","SecretAccessKey":"..."}' -U
Then in ~/.aws/config:
[default]
region = us-east-1
credential_process = security find-generic-password -a aws-default -s aws-credentials -w
AWS CLI, SDKs, Terraform — they all just call the credential_process and get the credentials back. No more plaintext ~/.aws/credentials file.
There are plenty of other ways to handle AWS authentication — SSO, IAM roles, temporary credentials — but if you're still using access keys, this is a quick win to get them off disk.
Wrapping Up
The shell wrapper approach does make the MCP config a little harder to read, and debugging connection issues takes an extra step since you need to check both the config and the Keychain entry. But for production credentials, that's a pretty easy trade-off — encrypted at rest, protected by your login password, and no risk of accidentally committing secrets to git.
The secret was already on your Mac all along. We just needed to use it.