Purpose: Read API keys from the macOS Keychain at runtime instead of
.env. · Lang: TS + Python · Deps: none (macOSsecurityCLI) Copy to: your project — keep the filename + this README. · Use when: you want secrets off-disk on a dev machine; not Linux CI (inject via env there). · Related: secret-guard, claude-code settings
Store API keys in the macOS Keychain instead of .env files, and read them at
runtime with the helper in your language of choice. Both helpers shell out to the
built-in security CLI, so there is zero runtime dependency to install.
Convention used here: a single flat keyring.
- service =
dev-keys(the same for every key) - account = the key name, e.g.
ANTHROPIC_API_KEY
# Store a secret (prompts for the value; -w with no arg keeps it off your shell history)
security add-generic-password -s dev-keys -a ANTHROPIC_API_KEY -w
# Store / overwrite non-interactively (-U updates if it already exists)
security add-generic-password -s dev-keys -a ANTHROPIC_API_KEY -w 'sk-ant-...' -U
# Retrieve a secret to stdout
security find-generic-password -s dev-keys -a ANTHROPIC_API_KEY -w
# Delete a secret
security delete-generic-password -s dev-keys -a ANTHROPIC_API_KEY
# List every account stored under this service (the closest thing to "list mine")
security dump-keychain | grep -A1 '"svce".*"dev-keys"' | grep '"acct"'
security dump-keychainwalks the whole login keychain; the grep narrows it to accounts filed underdev-keys. There is no first-class "list by service" command, so this is the practical recipe.
TypeScript / Node — see get-secret.ts:
import { getSecret } from './get-secret';
const apiKey = getSecret('ANTHROPIC_API_KEY');Python — see get_secret.py:
from get_secret import get_secret
api_key = get_secret("ANTHROPIC_API_KEY")Both default the service to dev-keys; pass a second argument to override.
Run either helper directly to verify a key is retrievable. To avoid spilling the secret to your terminal, the CLI prints only the last six characters — use the library API in code to get the real value:
python get_secret.py ANTHROPIC_API_KEY
# ✓ ANTHROPIC_API_KEY retrieved from 'dev-keys' (…AbC123)
npx tsx get-secret.ts ANTHROPIC_API_KEY
# ✓ ANTHROPIC_API_KEY retrieved from 'dev-keys' (…AbC123)The point of pulling secrets from the Keychain is to keep them out of .env
files and off disk. A few patterns below preserve that guarantee — it is easy to
quietly undo it.
Keep the same file name when you copy the helper into a new project — rename only
on a genuine collision — and keep it as a standalone file rather than folding it
into an existing module. That keeps it easy to spot and to re-sync from this
library later. Copy this README.md over too, so the guidance below travels with
the code. Once it's integrated you can of course adapt the code to the project.
Read the secret and hand it directly to the client that needs it. Do not
assign it back into process.env / os.environ. Once a secret is in the
environment it leaks into every child process, shows up in env, and can land in
crash dumps and logs.
# ❌ anti-pattern — the key is now visible to `env` and every subprocess
import os
os.environ["GOOGLE_API_KEY"] = get_secret("GOOGLE_API_KEY")
agent = Agent(...) # SDK reads it back out of the environment
# ✅ pass it straight to the client
agent = Agent(..., api_key=get_secret("GOOGLE_API_KEY"))This bit a recent project: a module using Google's Agent Development Kit fetched the key from the Keychain but then set it in the environment so the SDK would find it — making it visible to
env. If an SDK only reads from the environment, scope the assignment as tightly as possible (set it immediately before the call anddelit after) rather than at module import.
The secret value is in the Keychain — but the account name and service
used to look it up are still configuration. Don't hardcode them into a committed
source file (e.g. config.py). Read them from environment variables and keep
those in a git-ignored file.
# ❌ config.py — gets committed; bakes the lookup into the repo
KEYCHAIN_ACCOUNT = "ACME_PROD_API_KEY"
KEYCHAIN_SERVICE = "dev-keys"
# ✅ read the lookup config from the environment, with sensible defaults
import os
account = os.environ["KEYCHAIN_ACCOUNT"] # required, not in git
service = os.environ.get("KEYCHAIN_SERVICE", "dev-keys") # default is fine to commit
api_key = get_secret(account, service)Put the names in a git-ignored .env (loaded by your shell or a dotenv loader),
and commit a .env.example documenting which variables are
expected — names only, never values:
# .env.example (committed — documents the contract)
KEYCHAIN_SERVICE=dev-keys
KEYCHAIN_ACCOUNT=ANTHROPIC_API_KEY# .gitignore
.env- Never log or print the value. Redact it in error messages and avoid dumping config objects that hold it. The helpers here put only the account and service in their error text, never the secret.
- Don't cache it on disk or in long-lived globals. Fetch at the point of use;
the
securitycall is cheap. A module-level constant holding the plaintext defeats the purpose and widens its blast radius. - Fail fast and loud. Use
get_secret(raises) for required keys so a missing secret stops startup with a clear message; reservetry_get_secret/tryGetSecretfor genuinely optional ones. - This is macOS-only.
securitydoes not exist in Linux CI or containers. Keep the call behind a small accessor (as here) so CI can inject the secret a different way — e.g. fall back to an environment variable provided by your CI secret store — without changing call sites.