Overview
envchain reads secrets from the macOS Keychain and injects them directly into a subprocess as environment variables. No .env file is ever written to disk. npm run dev reads from Keychain and starts your server — nothing else to do.
bashenvchain myapp next dev # Keychain → process env → Next.js
Prerequisites
- macOS
- Homebrew
Install
bashbrew install envchain
Store a secret
bashenvchain -s myapp DATABASE_URL
# DATABASE_URL: (type value, input hidden)
-s stores a value. The namespace (myapp) groups your project's secrets — use your project slug. To verify it stored correctly:
bashenvchain myapp printenv DATABASE_URL
To delete an entry:
bashsecurity delete-generic-password -a "DATABASE_URL" -s "envchain.myapp"
Migrate an existing .env
This script reads KEY=value pairs from a .env file, prompts you on each one, stores accepted values in the Keychain under the envchain namespace, then backs up and deletes the original. No plain text is written back to disk at any point.
bash#!/bin/bash
# migrate-env-to-envchain.sh
# Usage: ./migrate-env-to-envchain.sh [env-file] [namespace]
# env-file: defaults to .env
# namespace: defaults to myapp — use your project slug
ENV_FILE="${1:-.env}"
NAMESPACE="${2:-myapp}"
if [[ ! -f "$ENV_FILE" ]]; then
echo "No $ENV_FILE found."
exit 1
fi
echo "Namespace: $NAMESPACE"
echo "Reading from $ENV_FILE"
echo ""
stored=0
while IFS= read -r line; do
[[ -z "$line" ]] && continue
[[ "$line" =~ ^[[:space:]]*# ]] && continue
key=$(echo "$line" | cut -d= -f1 | tr -d '[:space:]')
value=$(echo "$line" | cut -d= -f2-)
value=$(echo "$value" | sed -E "s/^[\"']//; s/[\"']$//")
[[ -z "$key" ]] || [[ -z "$value" ]] && continue
preview="$value"
if [[ ${#preview} -gt 40 ]]; then
preview="${preview:0:20}...${preview: -10}"
fi
echo "Found: $key=$preview"
read -p " Store in envchain? (y/n/q) " -n 1 -r < /dev/tty
echo ""
if [[ $REPLY =~ ^[Qq]$ ]]; then break; fi
if [[ ! $REPLY =~ ^[Yy]$ ]]; then echo " Skipped."; continue; fi
security delete-generic-password -a "$key" -s "envchain.$NAMESPACE" > /dev/null 2>&1 || true
if security add-generic-password -A -a "$key" -s "envchain.$NAMESPACE" -w "$value" 2>/dev/null; then
echo " Stored $key"
((stored++))
else
echo " Failed to store $key"
fi
done < "$ENV_FILE"
echo ""
if [[ $stored -eq 0 ]]; then
echo "No keys stored. Nothing to do."
exit 0
fi
cp "$ENV_FILE" "${ENV_FILE}.bak"
echo "Backed up $ENV_FILE → ${ENV_FILE}.bak"
GITIGNORE=".gitignore"
if [[ -f "$GITIGNORE" ]] && grep -qxF "$ENV_FILE" "$GITIGNORE"; then
echo "$ENV_FILE already in .gitignore"
else
echo "$ENV_FILE" >> "$GITIGNORE"
echo "Added $ENV_FILE to .gitignore"
fi
rm "$ENV_FILE"
echo "Deleted $ENV_FILE"
echo ""
echo "Done. Verify with: envchain $NAMESPACE printenv"
bashchmod +x migrate-env-to-envchain.sh
./migrate-env-to-envchain.sh # reads .env, namespace myapp
./migrate-env-to-envchain.sh .env.local myapp # specify both
No password prompts. The
-Aflag stores each item with "allow any application" access — Keychain won't ask for confirmation on every read. The login Keychain still locks on sleep and logout, so your secrets aren't exposed on a locked machine.
Namespace convention: use your project slug (e.g.
solutionscay, notmyapp). Avoids collisions if you run multiple projects on the same machine.
Wire up your dev command
envchain works with any runtime — just prefix your start command with envchain <namespace>.
Node.js / package.json
json{
"scripts": {
"dev": "envchain myapp next dev"
}
}
Python / Makefile
makefiledev:
envchain myapp python manage.py runserver
Go, Ruby, anything else
bashenvchain myapp go run main.go
envchain myapp rails server
The migration script is the same regardless of stack — only this wiring step changes per project type.
To add a new key to an existing project:
bashenvchain -s myapp NEW_KEY
# already available on next run — nothing else to change
Agent access
No file to read. Without a .env on disk, an agent has nothing to cat or Read. This is the real improvement over any gen-env pattern.
Shell environment is isolated. envchain injects secrets into the next dev subprocess only — not into the agent's shell. A bare echo $DATABASE_URL in an agent bash call returns nothing.
Two paths remain. An agent with shell access can call security find-generic-password directly, or run envchain myapp printenv to dump everything at once. Block both in ~/.claude/settings.json:
json{
"permissions": {
"deny": [
"Bash(security find-generic-password*)",
"Bash(envchain*)"
]
}
}
The envchain* deny does not block npm run dev — npm spawns envchain as a subprocess, which is not a direct agent bash call and is not intercepted by the deny rule. The agent can start your dev server without being able to read the secrets out of Keychain directly.
With these in place, the agent can use your running app without a path to the raw values.
Trade-offs
macOS only
envchain uses the macOS Keychain on Mac and gnome-keyring on Linux. Windows needs a different tool. For cross-platform teams, 1Password CLI or Doppler are the equivalents.
Rotating a key
bashsecurity delete-generic-password -a "DATABASE_URL" -s "envchain.myapp"
envchain -s myapp DATABASE_URL # prompts for new value
CI/CD
This covers local development. GitHub Actions, Vercel, and Render can't reach your Keychain. CI secrets stay in your platform's secrets manager.
