{"slug": "managing-development-secrets-on-a-mac", "title": "Managing Development Secrets on a Mac", "summary": "A developer outlines a secure method for managing API keys and secrets on macOS using Keychain for machine-level secrets, the 1Password CLI for project-specific secrets, and direnv for automatic loading, after a close call with a leaked API key on GitHub.", "body_md": "I once pushed a real API key to a public GitHub repo. It was in a “temporary” config that I hadn’t touched in a while. Thankfully, [GitHub’s secret scanning](https://docs.github.com/en/code-security/concepts/secret-security/secret-scanning) emailed me, and nothing bad came of it. This problem is becoming more relevant in recent years with AI skills requiring machine-wide configs for external access to MCPs and APIs, which would otherwise end up in regular `rc`\n\n[dotfiles](/blog/manage-ai-config-with-dotfiles.html). That scare forced me to clean up and rethink how I store secrets on my Mac, and I’ve been running the same setup since: **Keychain** for machine-level secrets, the **1Password CLI** for anything a project needs, and **direnv** to load them automatically.\n\nHere’s how you can set it up too.\n\n## Why not just use .env files?\n\nBefore the cleanup, my secrets lived wherever was convenient. A `.env`\n\nfile in every project, and a block of `export`\n\nlines in `.zshrc`\n\nthat followed me into every shell, related project or not. Each file was one wrong `git add`\n\naway from being public.\n\nHaving a `.env`\n\nfile also complicates workflows involving git worktrees, which means extra scripting when preparing your directory for work. This has become more relevant to me when running parallel AI coding sessions – I use git worktrees to prevent code clashing between sessions.\n\nI’m in good company, too. GitHub detected [more than 39 million leaked secrets](https://github.blog/security/application-security/next-evolution-github-advanced-security/) across its repositories in 2024 alone – over 100,000 a day, and that’s just the ones its scanner recognizes.\n\nSo the leaked key wasn’t bad luck. A secret that sits in plaintext for months will eventually catch a mistake. The fix is to sort your secrets by how they’re used, and give each kind a proper home.\n\n## What goes in Keychain?\n\nYour Mac already ships with an encrypted secret store, and most of us never touch it outside of Safari passwords. The `security`\n\ncommand talks to it directly:\n\n```\nsecurity add-generic-password -a \"$USER\" -s \"openai-api-key\" -w\nsecurity find-generic-password -a \"$USER\" -s \"openai-api-key\" -w\n```\n\nThat trailing `-w`\n\nwith no value is deliberate: `security`\n\nprompts for the secret instead of taking it as an argument, so the key never lands in your shell history – Apple’s own usage text calls inlining the password insecure. Add `-U`\n\nif you want the command to update an item that already exists.\n\nI use it for secrets that belong to *this machine* rather than to a project: a personal GitHub token used by one script, a webhook secret I only need for five minutes of debugging. This emptied out most of the `export`\n\nlines in my `.zshrc`\n\n. That matters double if you [keep your dotfiles in a git repo](/blog/manage-ai-config-with-dotfiles.html) like I do – an exported key in `.zshrc`\n\nis one `git push`\n\naway from leaking the same way my API key did.\n\nIf a tool insists on finding its key in the environment, you can still export it from `.zshrc`\n\n– just make Keychain the source instead of pasting the value:\n\n```\n# .zshrc -- safe to commit, the value never appears in the file\nexport OPENAI_API_KEY=$(security find-generic-password -a \"$USER\" -s \"openai-api-key\" -w)\n```\n\nOne caveat: this runs on every new shell, and each lookup adds a few dozen milliseconds to your startup. For a key you only need occasionally, wrap it in a function instead, and it gets read only when called:\n\n```\n# .zshrc\nopenai_key() {\n  export OPENAI_API_KEY=$(security find-generic-password -a \"$USER\" -s \"openai-api-key\" -w)\n}\n```\n\nKeychain is local by design, which is also its limit. Anything another machine or a teammate needs has to live somewhere else.\n\n## How do I keep project secrets out of .env files?\n\nThis is where the 1Password CLI comes in (`brew install --cask 1password-cli`\n\n, then enable Settings → Developer → “Integrate with 1Password CLI” in the desktop app so `op`\n\nauthenticates with Touch ID instead of making you sign in manually). Instead of copying values into a `.env`\n\nfile, the file holds `op://vault/item/field`\n\nreferences, and `op run`\n\nresolves them when the process starts:\n\n```\nDATABASE_URL=op://Dev/postgres-local/connection-string\nSTRIPE_SECRET_KEY=op://Dev/stripe/secret-key\nop run --env-file=.env -- npm start\n```\n\nThere is nothing in that `.env`\n\nfile to steal, so it’s safe to commit. The real values only ever exist in the environment of that one process – and as a bonus, `op run`\n\nmasks them if they ever show up in the process output. And since references resolve at runtime, rotating a key in 1Password updates every project and every machine at once – something my scattered `.env`\n\ncopies could never do.\n\n## How do I load secrets automatically?\n\nThe missing piece is not having to remember any of this. [direnv](https://direnv.net/) (`brew install direnv`\n\n, then hook it into your shell by adding `eval \"$(direnv hook zsh)\"`\n\nto the end of `.zshrc`\n\n) watches for an `.envrc`\n\nfile in each directory: it loads when you `cd`\n\nin and unloads when you `cd`\n\nout.\n\n```\n# .envrc\nexport DATABASE_URL=$(op read \"op://Dev/postgres-local/connection-string\")\nexport STRIPE_SECRET_KEY=$(op read \"op://Dev/stripe/secret-key\")\n```\n\nKeep `.envrc`\n\ngitignored like you would a `.env`\n\n, but notice what’s inside: references, not values. A teammate clones the repo, runs `direnv allow`\n\n, and needs access to the shared vault – not a Slack message with keys pasted in. As a bonus, your variables stop bleeding into unrelated shells, which is exactly how one of my old `.zshrc`\n\nexports ended up in a project it had nothing to do with.\n\n### What about docker compose?\n\nThis setup plays nicely with Docker, too. Compose substitutes `${VARIABLES}`\n\nin `docker-compose.yml`\n\nfrom the shell environment, so with direnv the values are already loaded by the time you type `docker compose up`\n\n:\n\n```\nservices:\n  app:\n    environment:\n      DATABASE_URL: ${DATABASE_URL}\n      STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}\n```\n\nTwo traps to avoid here. First, Compose also reads a `.env`\n\nfile in the project directory for the same substitution – including the reference-only one from the previous section. The shell environment takes precedence, so with direnv loaded the real values win; but without direnv (a teammate who skipped setup, a CI runner), Compose silently substitutes the literal `op://`\n\nstrings instead. If that bites you, name the reference file `.env.op`\n\n– Compose ignores it, and `op run --env-file=.env.op`\n\npicks it up all the same. Second, never hardcode values under `environment:`\n\nin `docker-compose.yml`\n\nitself; that file is committed, which puts you right back where I started.\n\nIf you’d rather not export variables at all, wrap the command instead, and the references get resolved just for that one process:\n\n```\nop run --env-file=.env -- docker compose up\n```\n\n## What if I slip up anyway?\n\nMistakes still happen. A config file keeps a real key as a fallback default – sound familiar? I run [gitleaks](https://github.com/gitleaks/gitleaks) (`brew install gitleaks`\n\n) as a pre-commit hook, so a commit containing anything key-shaped never leaves my machine:\n\n```\ngitleaks git --pre-commit --staged --redact -v\n```\n\n(If you’ve seen `gitleaks protect --staged`\n\nin older guides: it still works, but it’s been deprecated since v8.19.0 in favor of the `git`\n\nsubcommand.)\n\nIt has caught exactly one real secret for me. Still, one more than zero.\n\nIf a secret does make it into git history, rotate it. Optionally, you can rewrite history with `git filter-repo`\n\nto scrub it – but treat that as cleanup, not containment: on GitHub, the old commits can live on in forks and cached views long after the rewrite.\n\n## The routine\n\nA new project now starts with an `.envrc`\n\nfull of `op read`\n\ncalls, a committed `.env.example`\n\nshowing which references it needs, and gitleaks on every commit. One-off scripts on my own machine get Keychain. The rule behind all of it is simple: a secret never gets typed into a plaintext file, and everything else follows from that.\n\nGive this a go, and [let me know](mailto:hello@yurikoval.com?subject=Managing%20Secrets) if you have a way to improve this workflow!", "url": "https://wpnews.pro/news/managing-development-secrets-on-a-mac", "canonical_source": "https://www.yurikoval.com/blog/managing-development-secrets-on-a-mac.html", "published_at": "2026-07-07 13:20:04+00:00", "updated_at": "2026-07-07 13:30:03.040933+00:00", "lang": "en", "topics": ["developer-tools", "ai-safety"], "entities": ["GitHub", "1Password", "Keychain", "direnv", "macOS"], "alternates": {"html": "https://wpnews.pro/news/managing-development-secrets-on-a-mac", "markdown": "https://wpnews.pro/news/managing-development-secrets-on-a-mac.md", "text": "https://wpnews.pro/news/managing-development-secrets-on-a-mac.txt", "jsonld": "https://wpnews.pro/news/managing-development-secrets-on-a-mac.jsonld"}}