< back to cookbook
RECIPE 04

Dev Secrets with envchain

Store secrets in the macOS Keychain and inject them directly into your dev process. No .env file, no plain text on disk, nothing to run before npm run dev.

May 1, 2026

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

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 -A flag 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, not myapp). 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.